feat: 初始化考培练系统项目
- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
1
.cursorignore
Normal file
1
.cursorignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# 不忽略任何文件,所有文件均可被Cursor访问和编辑
|
||||||
18
.cursorrules
Normal file
18
.cursorrules
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Cursor 规则配置文件
|
||||||
|
# 允许查看和编辑私钥文件
|
||||||
|
|
||||||
|
# 文件访问规则
|
||||||
|
- 允许查看 .pem 文件
|
||||||
|
- 允许查看 .key 文件
|
||||||
|
- 允许查看 .crt 文件
|
||||||
|
- 允许查看 .cert 文件
|
||||||
|
|
||||||
|
# 安全提醒
|
||||||
|
- 私钥文件包含敏感信息,请谨慎处理
|
||||||
|
- 建议使用环境变量管理密钥
|
||||||
|
- 不要将私钥提交到版本控制系统
|
||||||
|
|
||||||
|
# 项目特定规则
|
||||||
|
- 本项目使用 Python + Vue3 + MySQL + FastAPI
|
||||||
|
- 支持 Docker 容器化部署
|
||||||
|
- 使用中文注释和文档
|
||||||
142
.drone.yml
Normal file
142
.drone.yml
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: test-deploy
|
||||||
|
|
||||||
|
# 仅在 test 分支触发测试环境部署
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- test
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Step 1: 构建后端镜像
|
||||||
|
- name: build-backend
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
registry: registry.cn-shenzhen.aliyuncs.com
|
||||||
|
repo: registry.cn-shenzhen.aliyuncs.com/ruimeiyun/kaopeilian-backend
|
||||||
|
username:
|
||||||
|
from_secret: docker_username
|
||||||
|
password:
|
||||||
|
from_secret: docker_password
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
context: backend
|
||||||
|
tags:
|
||||||
|
- test
|
||||||
|
- ${DRONE_COMMIT_SHA:0:8}
|
||||||
|
|
||||||
|
# Step 2: 构建前端镜像
|
||||||
|
- name: build-frontend
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
registry: registry.cn-shenzhen.aliyuncs.com
|
||||||
|
repo: registry.cn-shenzhen.aliyuncs.com/ruimeiyun/kaopeilian-frontend
|
||||||
|
username:
|
||||||
|
from_secret: docker_username
|
||||||
|
password:
|
||||||
|
from_secret: docker_password
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
context: frontend
|
||||||
|
tags:
|
||||||
|
- test
|
||||||
|
- ${DRONE_COMMIT_SHA:0:8}
|
||||||
|
|
||||||
|
# Step 3: 部署到测试服务器
|
||||||
|
- name: deploy-test
|
||||||
|
image: appleboy/drone-ssh
|
||||||
|
settings:
|
||||||
|
host: 47.107.172.23
|
||||||
|
username: root
|
||||||
|
password:
|
||||||
|
from_secret: ssh_password
|
||||||
|
port: 22
|
||||||
|
script:
|
||||||
|
- echo "=== 部署考培练系统测试环境 ==="
|
||||||
|
- cd /data/kaopeilian-test || mkdir -p /data/kaopeilian-test
|
||||||
|
- |
|
||||||
|
cat > docker-compose.yml << 'EOF'
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: registry.cn-shenzhen.aliyuncs.com/ruimeiyun/kaopeilian-backend:test
|
||||||
|
container_name: kaopeilian-backend-test
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "18000:8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- REDIS_HOST=${REDIS_HOST}
|
||||||
|
- REDIS_PORT=${REDIS_PORT}
|
||||||
|
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||||
|
networks:
|
||||||
|
- kaopeilian-net
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: registry.cn-shenzhen.aliyuncs.com/ruimeiyun/kaopeilian-frontend:test
|
||||||
|
container_name: kaopeilian-frontend-test
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "13001:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- kaopeilian-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
kaopeilian-net:
|
||||||
|
driver: bridge
|
||||||
|
EOF
|
||||||
|
- docker-compose pull
|
||||||
|
- docker-compose up -d
|
||||||
|
- docker ps | grep kaopeilian
|
||||||
|
- echo "=== 部署完成 ==="
|
||||||
|
|
||||||
|
# Step 4: 通知部署结果
|
||||||
|
- name: notify
|
||||||
|
image: plugins/webhook
|
||||||
|
settings:
|
||||||
|
urls:
|
||||||
|
from_secret: webhook_url
|
||||||
|
content_type: application/json
|
||||||
|
template: |
|
||||||
|
{
|
||||||
|
"msgtype": "text",
|
||||||
|
"text": {
|
||||||
|
"content": "🚀 考培练系统测试环境部署完成\n分支: ${DRONE_BRANCH}\n提交: ${DRONE_COMMIT_SHA:0:8}\n作者: ${DRONE_COMMIT_AUTHOR}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when:
|
||||||
|
status:
|
||||||
|
- success
|
||||||
|
- failure
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: code-check
|
||||||
|
|
||||||
|
# 所有分支推送时进行代码检查
|
||||||
|
trigger:
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
- pull_request
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Python 代码检查
|
||||||
|
- name: python-lint
|
||||||
|
image: python:3.9-slim
|
||||||
|
commands:
|
||||||
|
- cd backend
|
||||||
|
- pip install flake8 -q
|
||||||
|
- flake8 app --count --select=E9,F63,F7,F82 --show-source --statistics || true
|
||||||
|
- echo "Python lint completed"
|
||||||
|
|
||||||
|
# Node.js 代码检查
|
||||||
|
- name: frontend-lint
|
||||||
|
image: node:18-alpine
|
||||||
|
commands:
|
||||||
|
- cd frontend
|
||||||
|
- npm install -q 2>/dev/null || true
|
||||||
|
- npm run lint 2>/dev/null || echo "Frontend lint completed"
|
||||||
20
.env.admin
Normal file
20
.env.admin
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# ============================================
|
||||||
|
# 考培练系统 SaaS 管理后台环境变量
|
||||||
|
#
|
||||||
|
# 注意:此文件包含敏感信息,请确保:
|
||||||
|
# 1. 文件权限设置为 600(chmod 600 .env.admin)
|
||||||
|
# 2. 不要提交到 Git 仓库
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 管理后台数据库配置
|
||||||
|
ADMIN_DB_HOST=prod-mysql
|
||||||
|
ADMIN_DB_PORT=3306
|
||||||
|
ADMIN_DB_USER=root
|
||||||
|
ADMIN_DB_PASSWORD=ProdMySQL2025!@#
|
||||||
|
ADMIN_DB_NAME=kaopeilian_admin
|
||||||
|
|
||||||
|
# JWT 密钥
|
||||||
|
ADMIN_JWT_SECRET=admin-secret-key-kaopeilian-2026-production
|
||||||
|
|
||||||
|
# JWT Token 过期时间(秒)
|
||||||
|
ADMIN_JWT_EXPIRE_SECONDS=86400
|
||||||
25
.env.development
Normal file
25
.env.development
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# 全局开发环境配置文件
|
||||||
|
WORKSPACE_NAME="本地开发与测试"
|
||||||
|
ENVIRONMENT="development"
|
||||||
|
DEBUG=true
|
||||||
|
LOG_LEVEL="DEBUG"
|
||||||
|
|
||||||
|
# 端口分配
|
||||||
|
PYTHON_DEV_PORT=8000
|
||||||
|
COZE_BACKEND_PORT=8001
|
||||||
|
COZE_FRONTEND_PORT=3000
|
||||||
|
|
||||||
|
# 数据库配置
|
||||||
|
MYSQL_HOST="localhost"
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER="root"
|
||||||
|
MYSQL_PASSWORD="password"
|
||||||
|
|
||||||
|
# Redis配置
|
||||||
|
REDIS_HOST="localhost"
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# 开发模式
|
||||||
|
HOT_RELOAD=true
|
||||||
|
AUTO_RELOAD=true
|
||||||
|
DEVELOPMENT_MODE=true
|
||||||
26
.env.kpl
Normal file
26
.env.kpl
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# 瑞小美团队 AI 服务配置
|
||||||
|
# ⚠️ 此文件包含敏感信息,禁止提交到 Git
|
||||||
|
# 文件权限应设置为 600
|
||||||
|
|
||||||
|
# === AI 服务配置 ===
|
||||||
|
# 通用 Key(用于 Gemini/DeepSeek 等非 Claude 模型)
|
||||||
|
AI_PRIMARY_API_KEY=sk-V9QfxYscJzD54ZRIDyAvnd4Lew4IdtPUQqwDQ0swNkIYObxT
|
||||||
|
|
||||||
|
# Claude 专属 Key(用于 Claude 模型)
|
||||||
|
AI_ANTHROPIC_API_KEY=sk-wNAkUW3zwBeKBN8EeK5KnXXgOixnW5rhZmKolo8fBQuHepkX
|
||||||
|
|
||||||
|
# OpenRouter 备选 Key(可选,用于降级)
|
||||||
|
AI_FALLBACK_API_KEY=
|
||||||
|
|
||||||
|
# === 数据库配置 ===
|
||||||
|
MYSQL_PASSWORD=nj861021
|
||||||
|
|
||||||
|
# 租户配置(用于多租户部署)
|
||||||
|
TENANT_CODE=kpl
|
||||||
|
|
||||||
|
# 管理库连接配置(用于从 tenant_configs 表读取配置)
|
||||||
|
ADMIN_DB_HOST=prod-mysql
|
||||||
|
ADMIN_DB_PORT=3306
|
||||||
|
ADMIN_DB_USER=root
|
||||||
|
ADMIN_DB_PASSWORD=ProdMySQL2025!@#
|
||||||
|
ADMIN_DB_NAME=kaopeilian_admin
|
||||||
71
.gitignore
vendored
Normal file
71
.gitignore
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# ================================
|
||||||
|
# AgentWD 项目 .gitignore
|
||||||
|
# ================================
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# 环境配置(敏感)
|
||||||
|
# ----------------
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.env.production
|
||||||
|
.env.staging
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# 依赖目录
|
||||||
|
# ----------------
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# 构建产物
|
||||||
|
# ----------------
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.output/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# IDE 配置
|
||||||
|
# ----------------
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# 日志文件
|
||||||
|
# ----------------
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# 测试覆盖率
|
||||||
|
# ----------------
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# n8n 敏感信息
|
||||||
|
# ----------------
|
||||||
|
n8n-workflows/*-credentials.json
|
||||||
|
n8n-workflows/credentials.json
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# 历史备份(.history插件)
|
||||||
|
# ----------------
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# 临时文件
|
||||||
|
# ----------------
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
.cache/
|
||||||
124
CONTEXT.md
Normal file
124
CONTEXT.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# 项目上下文
|
||||||
|
|
||||||
|
> AI启动时必读此文件,快速了解项目全貌
|
||||||
|
|
||||||
|
## 一、项目信息
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **项目编号** | 012-考培练系统-2601 |
|
||||||
|
| **项目路径** | `projects/012-考培练系统-2601/` |
|
||||||
|
| **当前阶段** | 开发阶段 |
|
||||||
|
| **项目状态** | 🟢 活跃 |
|
||||||
|
| **启动日期** | 2026-01-24 |
|
||||||
|
| **技术栈** | Vue3 + TypeScript + FastAPI + MySQL |
|
||||||
|
|
||||||
|
## 二、AI启动指令
|
||||||
|
|
||||||
|
请依次阅读以下文件:
|
||||||
|
|
||||||
|
1. **框架层**(了解规则)
|
||||||
|
- `../../_framework/agents/00-框架总览.md`
|
||||||
|
- 检查 `agents/` 是否有项目覆盖
|
||||||
|
|
||||||
|
2. **项目文档**(了解当前状态)
|
||||||
|
- `docs/同步清单.md`
|
||||||
|
- `docs/项目状态快照.md`
|
||||||
|
- `docs/规划/系统架构.md`
|
||||||
|
|
||||||
|
3. **技术文档**
|
||||||
|
- `docs/README.md` - 项目总览
|
||||||
|
- `backend/README.md` - 后端开发指南
|
||||||
|
|
||||||
|
## 三、项目简介
|
||||||
|
|
||||||
|
考培练系统是一个革命性的员工能力提升平台,专为轻医美连锁品牌瑞小美打造。通过集成 Coze 和 Dify 双 AI 平台,实现智能化的培训、考核和陪练功能。
|
||||||
|
|
||||||
|
### 核心功能
|
||||||
|
|
||||||
|
- **智能考试系统**:动态题目生成(千人千卷)、三轮考试机制
|
||||||
|
- **AI陪练中心**:模拟客户对话、语音交互支持
|
||||||
|
- **课程管理**:知识点自动提取、分岗位内容推送
|
||||||
|
- **数据分析**:能力雷达图、学习进度追踪
|
||||||
|
|
||||||
|
### 技术架构
|
||||||
|
|
||||||
|
| 层级 | 技术栈 |
|
||||||
|
|------|--------|
|
||||||
|
| 前端 | Vue3 + TypeScript + Element Plus + Vite |
|
||||||
|
| 后端 | Python 3.9+ + FastAPI + SQLAlchemy |
|
||||||
|
| 数据库 | MySQL 8.0 + Redis |
|
||||||
|
| AI平台 | Dify(动态考试)+ Coze(AI陪练) |
|
||||||
|
| 部署 | Docker 容器化 |
|
||||||
|
|
||||||
|
## 四、项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
012-考培练系统-2601/
|
||||||
|
├── backend/ # 后端 (FastAPI)
|
||||||
|
│ ├── app/ # 应用主目录
|
||||||
|
│ │ ├── api/ # API路由
|
||||||
|
│ │ ├── models/ # 数据模型
|
||||||
|
│ │ ├── schemas/ # Pydantic schemas
|
||||||
|
│ │ └── services/ # 业务逻辑
|
||||||
|
│ └── requirements.txt
|
||||||
|
├── frontend/ # 用户端前端 (Vue3)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── api/ # API调用
|
||||||
|
│ ├── views/ # 页面视图
|
||||||
|
│ └── components/ # 组件
|
||||||
|
├── admin-frontend/ # 管理端前端 (Vue3)
|
||||||
|
├── deploy/ # 部署配置
|
||||||
|
│ ├── docker/ # Docker compose 文件
|
||||||
|
│ ├── nginx/ # Nginx 配置
|
||||||
|
│ └── scripts/ # 启动/部署脚本
|
||||||
|
├── docs/ # 文档
|
||||||
|
│ └── 规划/ # 系统规划文档
|
||||||
|
├── tests/ # 测试文件
|
||||||
|
├── 知识库/ # 开发记录、问题修复
|
||||||
|
└── .env.* # 环境配置
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五、关键配置
|
||||||
|
|
||||||
|
### 服务端口
|
||||||
|
|
||||||
|
| 服务 | 端口 |
|
||||||
|
|------|------|
|
||||||
|
| 前端 | 3001 |
|
||||||
|
| 后端 API | 8000 |
|
||||||
|
| MySQL | 3306 |
|
||||||
|
| Redis | 6379 |
|
||||||
|
|
||||||
|
### 系统账户
|
||||||
|
|
||||||
|
| 角色 | 用户名 | 密码 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 超级管理员 | superadmin | Superadmin123! |
|
||||||
|
| 系统管理员 | admin | Admin123! |
|
||||||
|
| 测试学员 | testuser | TestPass123! |
|
||||||
|
|
||||||
|
### 数据库连接
|
||||||
|
|
||||||
|
- **服务器**: 120.79.247.16
|
||||||
|
- **端口**: 3306
|
||||||
|
- **数据库**: kaopeilian
|
||||||
|
|
||||||
|
## 六、文件访问边界
|
||||||
|
|
||||||
|
| 区域 | 读取 | 写入 |
|
||||||
|
|------|------|------|
|
||||||
|
| ✅ 本项目目录 | 允许 | 允许 |
|
||||||
|
| ✅ `_framework/` | 允许 | ⚠️ 需确认 |
|
||||||
|
| ⚠️ `_private/` | 需许可 | ❌ **绝对禁止** |
|
||||||
|
| ❌ 其他项目 | 禁止 | 禁止 |
|
||||||
|
|
||||||
|
## 七、注意事项
|
||||||
|
|
||||||
|
- 多租户架构:支持多个机构独立部署(.env.fw, .env.hl 等)
|
||||||
|
- AI集成:需配置 Coze 和 Dify API 密钥
|
||||||
|
- 文件上传:使用 LibreOffice 转换 Office 文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 最后更新:2026-01-24
|
||||||
47
admin-frontend/Dockerfile
Normal file
47
admin-frontend/Dockerfile
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 考培练系统管理后台前端 Dockerfile
|
||||||
|
# 多阶段构建:Node.js 构建 + Nginx 运行
|
||||||
|
#
|
||||||
|
# 技术栈:Vue 3 + TypeScript + pnpm(符合瑞小美系统技术栈标准)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 阶段1:构建
|
||||||
|
# ============================================
|
||||||
|
FROM node:20.11-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装 pnpm(符合规范:使用 pnpm 包管理器)
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
|
||||||
|
|
||||||
|
# 设置 pnpm 镜像
|
||||||
|
RUN pnpm config set registry https://registry.npmmirror.com
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
RUN pnpm install --frozen-lockfile || pnpm install
|
||||||
|
|
||||||
|
# 复制源码并构建
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 阶段2:运行
|
||||||
|
# ============================================
|
||||||
|
FROM nginx:1.25.4-alpine
|
||||||
|
|
||||||
|
# 复制构建产物
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 复制 nginx 配置
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# 健康检查(符合规范)
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
|
||||||
|
|
||||||
|
# 启动 nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
||||||
22
admin-frontend/env.d.ts
vendored
Normal file
22
admin-frontend/env.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element Plus 中文语言包类型声明
|
||||||
|
declare module 'element-plus/dist/locale/zh-cn.mjs' {
|
||||||
|
const zhCn: any
|
||||||
|
export default zhCn
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
|
|
||||||
14
admin-frontend/index.html
Normal file
14
admin-frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>考培练系统 - 管理后台</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
47
admin-frontend/nginx.conf
Normal file
47
admin-frontend/nginx.conf
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# gzip 压缩
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml;
|
||||||
|
|
||||||
|
# 静态资源缓存
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTML 不缓存
|
||||||
|
location ~* \.html$ {
|
||||||
|
expires -1;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 代理到后端
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://kaopeilian-admin-backend:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA 路由支持
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
42
admin-frontend/package.json
Normal file
42
admin-frontend/package.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "kaopeilian-admin-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "考培练系统 SaaS 超级管理后台",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@9.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||||
|
"type-check": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.2.0",
|
||||||
|
"pinia": "^2.1.0",
|
||||||
|
"element-plus": "^2.5.0",
|
||||||
|
"@element-plus/icons-vue": "^2.3.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"monaco-editor": "^0.45.0",
|
||||||
|
"dayjs": "^1.11.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"sass": "^1.69.0",
|
||||||
|
"unplugin-auto-import": "^0.17.0",
|
||||||
|
"unplugin-vue-components": "^0.26.0",
|
||||||
|
"typescript": "~5.3.0",
|
||||||
|
"vue-tsc": "^2.0.0",
|
||||||
|
"@tsconfig/node20": "^20.1.0",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"@vue/tsconfig": "^0.5.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"@vue/eslint-config-typescript": "^13.0.0",
|
||||||
|
"eslint-plugin-vue": "^9.22.0",
|
||||||
|
"@rushstack/eslint-patch": "^1.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
19
admin-frontend/public/favicon.svg
Normal file
19
admin-frontend/public/favicon.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#409EFF;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#67C23A;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="32" height="32" rx="6" fill="url(#grad)"/>
|
||||||
|
<text x="16" y="22" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="white" text-anchor="middle">A</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 514 B |
18
admin-frontend/src/App.vue
Normal file
18
admin-frontend/src/App.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<el-config-provider :locale="zhCn">
|
||||||
|
<router-view />
|
||||||
|
</el-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body, #app {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
108
admin-frontend/src/api/index.js
Normal file
108
admin-frontend/src/api/index.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
// 创建 axios 实例
|
||||||
|
const request = axios.create({
|
||||||
|
baseURL: '/api/v1/admin',
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 请求拦截器
|
||||||
|
request.interceptors.request.use(
|
||||||
|
config => {
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
error => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
request.interceptors.response.use(
|
||||||
|
response => {
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
error => {
|
||||||
|
const { response } = error
|
||||||
|
if (response) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
localStorage.removeItem('admin_user')
|
||||||
|
router.push('/login')
|
||||||
|
ElMessage.error('登录已过期,请重新登录')
|
||||||
|
} else if (response.status === 403) {
|
||||||
|
ElMessage.error('没有权限执行此操作')
|
||||||
|
} else if (response.data?.detail) {
|
||||||
|
ElMessage.error(response.data.detail)
|
||||||
|
} else {
|
||||||
|
ElMessage.error('请求失败')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.error('网络错误')
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// API 模块
|
||||||
|
const api = {
|
||||||
|
// 认证
|
||||||
|
auth: {
|
||||||
|
login: data => request.post('/auth/login', data),
|
||||||
|
me: () => request.get('/auth/me'),
|
||||||
|
changePassword: data => request.post('/auth/change-password', data),
|
||||||
|
logout: () => request.post('/auth/logout'),
|
||||||
|
},
|
||||||
|
|
||||||
|
// 租户
|
||||||
|
tenants: {
|
||||||
|
list: params => request.get('/tenants', { params }),
|
||||||
|
get: id => request.get(`/tenants/${id}`),
|
||||||
|
create: data => request.post('/tenants', data),
|
||||||
|
update: (id, data) => request.put(`/tenants/${id}`, data),
|
||||||
|
delete: id => request.delete(`/tenants/${id}`),
|
||||||
|
enable: id => request.post(`/tenants/${id}/enable`),
|
||||||
|
disable: id => request.post(`/tenants/${id}/disable`),
|
||||||
|
},
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
configs: {
|
||||||
|
templates: params => request.get('/configs/templates', { params }),
|
||||||
|
groups: () => request.get('/configs/groups'),
|
||||||
|
getTenantConfigs: (tenantId, params) => request.get(`/configs/tenants/${tenantId}`, { params }),
|
||||||
|
updateConfig: (tenantId, group, key, data) => request.put(`/configs/tenants/${tenantId}/${group}/${key}`, data),
|
||||||
|
batchUpdate: (tenantId, data) => request.put(`/configs/tenants/${tenantId}/batch`, data),
|
||||||
|
deleteConfig: (tenantId, group, key) => request.delete(`/configs/tenants/${tenantId}/${group}/${key}`),
|
||||||
|
refreshCache: tenantId => request.post(`/configs/tenants/${tenantId}/refresh-cache`),
|
||||||
|
},
|
||||||
|
|
||||||
|
// 提示词
|
||||||
|
prompts: {
|
||||||
|
list: params => request.get('/prompts', { params }),
|
||||||
|
get: id => request.get(`/prompts/${id}`),
|
||||||
|
create: data => request.post('/prompts', data),
|
||||||
|
update: (id, data) => request.put(`/prompts/${id}`, data),
|
||||||
|
getVersions: id => request.get(`/prompts/${id}/versions`),
|
||||||
|
rollback: (id, version) => request.post(`/prompts/${id}/rollback/${version}`),
|
||||||
|
getTenantPrompts: tenantId => request.get(`/prompts/tenants/${tenantId}`),
|
||||||
|
updateTenantPrompt: (tenantId, promptId, data) => request.put(`/prompts/tenants/${tenantId}/${promptId}`, data),
|
||||||
|
deleteTenantPrompt: (tenantId, promptId) => request.delete(`/prompts/tenants/${tenantId}/${promptId}`),
|
||||||
|
},
|
||||||
|
|
||||||
|
// 功能开关
|
||||||
|
features: {
|
||||||
|
getDefaults: () => request.get('/features/defaults'),
|
||||||
|
getTenantFeatures: tenantId => request.get(`/features/tenants/${tenantId}`),
|
||||||
|
updateFeature: (tenantId, code, data) => request.put(`/features/tenants/${tenantId}/${code}`, data),
|
||||||
|
resetFeature: (tenantId, code) => request.delete(`/features/tenants/${tenantId}/${code}`),
|
||||||
|
batchUpdate: (tenantId, data) => request.post(`/features/tenants/${tenantId}/batch`, data),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default api
|
||||||
|
|
||||||
58
admin-frontend/src/assets/styles/main.scss
Normal file
58
admin-frontend/src/assets/styles/main.scss
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// 全局样式
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element Plus 样式覆盖
|
||||||
|
.el-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input {
|
||||||
|
.el-input__wrapper {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table {
|
||||||
|
th.el-table__cell {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动条样式
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #a8a8a8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
24
admin-frontend/src/main.ts
Normal file
24
admin-frontend/src/main.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './assets/styles/main.scss'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
// 注册所有图标
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus, { locale: zhCn })
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
|
|
||||||
96
admin-frontend/src/router/index.js
Normal file
96
admin-frontend/src/router/index.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('@/views/Login.vue'),
|
||||||
|
meta: { requiresAuth: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('@/views/Layout.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirect: '/dashboard'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('@/views/Dashboard.vue'),
|
||||||
|
meta: { title: '控制台' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tenants',
|
||||||
|
name: 'Tenants',
|
||||||
|
component: () => import('@/views/tenants/TenantList.vue'),
|
||||||
|
meta: { title: '租户管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tenants/:id',
|
||||||
|
name: 'TenantDetail',
|
||||||
|
component: () => import('@/views/tenants/TenantDetail.vue'),
|
||||||
|
meta: { title: '租户详情' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tenants/:id/configs',
|
||||||
|
name: 'TenantConfigs',
|
||||||
|
component: () => import('@/views/tenants/TenantConfigs.vue'),
|
||||||
|
meta: { title: '租户配置' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tenants/:id/features',
|
||||||
|
name: 'TenantFeatures',
|
||||||
|
component: () => import('@/views/tenants/TenantFeatures.vue'),
|
||||||
|
meta: { title: '功能开关' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'prompts',
|
||||||
|
name: 'Prompts',
|
||||||
|
component: () => import('@/views/prompts/PromptList.vue'),
|
||||||
|
meta: { title: '提示词管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'prompts/:id',
|
||||||
|
name: 'PromptDetail',
|
||||||
|
component: () => import('@/views/prompts/PromptDetail.vue'),
|
||||||
|
meta: { title: '提示词详情' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'logs',
|
||||||
|
name: 'Logs',
|
||||||
|
component: () => import('@/views/Logs.vue'),
|
||||||
|
meta: { title: '操作日志' }
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'NotFound',
|
||||||
|
component: () => import('@/views/NotFound.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
// 路由守卫
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth !== false && !authStore.isLoggedIn) {
|
||||||
|
next({ name: 'Login', query: { redirect: to.fullPath } })
|
||||||
|
} else if (to.name === 'Login' && authStore.isLoggedIn) {
|
||||||
|
next({ name: 'Dashboard' })
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
||||||
40
admin-frontend/src/stores/auth.js
Normal file
40
admin-frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref(localStorage.getItem('admin_token') || '')
|
||||||
|
const user = ref(JSON.parse(localStorage.getItem('admin_user') || 'null'))
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => !!token.value)
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
const res = await api.auth.login({ username, password })
|
||||||
|
token.value = res.access_token
|
||||||
|
user.value = res.admin_user
|
||||||
|
localStorage.setItem('admin_token', res.access_token)
|
||||||
|
localStorage.setItem('admin_user', JSON.stringify(res.admin_user))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
token.value = ''
|
||||||
|
user.value = null
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
localStorage.removeItem('admin_user')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUser() {
|
||||||
|
if (!token.value) return
|
||||||
|
try {
|
||||||
|
const res = await api.auth.me()
|
||||||
|
user.value = res
|
||||||
|
localStorage.setItem('admin_user', JSON.stringify(res))
|
||||||
|
} catch (e) {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, user, isLoggedIn, login, logout, fetchUser }
|
||||||
|
})
|
||||||
|
|
||||||
204
admin-frontend/src/views/Dashboard.vue
Normal file
204
admin-frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover" class="stat-card">
|
||||||
|
<div class="stat-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
|
||||||
|
<el-icon><OfficeBuilding /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ stats.tenantCount }}</div>
|
||||||
|
<div class="stat-label">租户总数</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover" class="stat-card">
|
||||||
|
<div class="stat-icon" style="background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);">
|
||||||
|
<el-icon><CircleCheck /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ stats.activeTenants }}</div>
|
||||||
|
<div class="stat-label">活跃租户</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover" class="stat-card">
|
||||||
|
<div class="stat-icon" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ stats.promptCount }}</div>
|
||||||
|
<div class="stat-label">提示词模板</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover" class="stat-card">
|
||||||
|
<div class="stat-icon" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
|
||||||
|
<el-icon><Setting /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ stats.configCount }}</div>
|
||||||
|
<div class="stat-label">配置项总数</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="20" style="margin-top: 20px;">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>租户列表</span>
|
||||||
|
<el-button type="primary" link @click="$router.push('/tenants')">
|
||||||
|
查看全部 <el-icon><ArrowRight /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table :data="tenants" style="width: 100%">
|
||||||
|
<el-table-column prop="name" label="租户名称" />
|
||||||
|
<el-table-column prop="domain" label="域名" />
|
||||||
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
|
||||||
|
{{ row.status === 'active' ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>快捷操作</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="quick-actions">
|
||||||
|
<el-button @click="$router.push('/tenants')">
|
||||||
|
<el-icon><OfficeBuilding /></el-icon>
|
||||||
|
管理租户
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="$router.push('/prompts')">
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
管理提示词
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="$router.push('/logs')">
|
||||||
|
<el-icon><List /></el-icon>
|
||||||
|
查看日志
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { OfficeBuilding, CircleCheck, Document, Setting, ArrowRight, List } from '@element-plus/icons-vue'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const stats = ref({
|
||||||
|
tenantCount: 0,
|
||||||
|
activeTenants: 0,
|
||||||
|
promptCount: 0,
|
||||||
|
configCount: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const tenants = ref([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// 获取租户列表
|
||||||
|
const res = await api.tenants.list({ page: 1, page_size: 5 })
|
||||||
|
tenants.value = res.items
|
||||||
|
stats.value.tenantCount = res.total
|
||||||
|
stats.value.activeTenants = res.items.filter(t => t.status === 'active').length
|
||||||
|
|
||||||
|
// 获取提示词数量
|
||||||
|
const prompts = await api.prompts.list()
|
||||||
|
stats.value.promptCount = prompts.length
|
||||||
|
|
||||||
|
// 获取配置模板数量
|
||||||
|
const configs = await api.configs.templates()
|
||||||
|
stats.value.configCount = configs.length
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.dashboard {
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 16px;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content {
|
||||||
|
.stat-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
padding: 20px 30px;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
234
admin-frontend/src/views/Layout.vue
Normal file
234
admin-frontend/src/views/Layout.vue
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<el-container class="layout-container">
|
||||||
|
<!-- 侧边栏 -->
|
||||||
|
<el-aside :width="isCollapsed ? '64px' : '220px'" class="layout-aside">
|
||||||
|
<div class="logo">
|
||||||
|
<span v-if="!isCollapsed">考培练管理</span>
|
||||||
|
<span v-else>KPL</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-menu
|
||||||
|
:default-active="activeMenu"
|
||||||
|
:collapse="isCollapsed"
|
||||||
|
:collapse-transition="false"
|
||||||
|
background-color="#1a1a2e"
|
||||||
|
text-color="#a0aec0"
|
||||||
|
active-text-color="#fff"
|
||||||
|
router
|
||||||
|
>
|
||||||
|
<el-menu-item index="/dashboard">
|
||||||
|
<el-icon><Odometer /></el-icon>
|
||||||
|
<template #title>控制台</template>
|
||||||
|
</el-menu-item>
|
||||||
|
|
||||||
|
<el-menu-item index="/tenants">
|
||||||
|
<el-icon><OfficeBuilding /></el-icon>
|
||||||
|
<template #title>租户管理</template>
|
||||||
|
</el-menu-item>
|
||||||
|
|
||||||
|
<el-menu-item index="/prompts">
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
<template #title>提示词管理</template>
|
||||||
|
</el-menu-item>
|
||||||
|
|
||||||
|
<el-menu-item index="/logs">
|
||||||
|
<el-icon><List /></el-icon>
|
||||||
|
<template #title>操作日志</template>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
|
||||||
|
<el-container>
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<el-header class="layout-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<el-icon
|
||||||
|
class="collapse-btn"
|
||||||
|
@click="isCollapsed = !isCollapsed"
|
||||||
|
>
|
||||||
|
<Fold v-if="!isCollapsed" />
|
||||||
|
<Expand v-else />
|
||||||
|
</el-icon>
|
||||||
|
|
||||||
|
<el-breadcrumb separator="/">
|
||||||
|
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||||
|
<el-breadcrumb-item v-if="$route.meta.title">
|
||||||
|
{{ $route.meta.title }}
|
||||||
|
</el-breadcrumb-item>
|
||||||
|
</el-breadcrumb>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<el-dropdown @command="handleCommand">
|
||||||
|
<span class="user-info">
|
||||||
|
<el-avatar :size="32" :icon="UserFilled" />
|
||||||
|
<span class="username">{{ authStore.user?.full_name || authStore.user?.username }}</span>
|
||||||
|
<el-icon><ArrowDown /></el-icon>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="password">修改密码</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
</el-header>
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<el-main class="layout-main">
|
||||||
|
<router-view />
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-container>
|
||||||
|
|
||||||
|
<!-- 修改密码对话框 -->
|
||||||
|
<el-dialog v-model="passwordDialogVisible" title="修改密码" width="400px">
|
||||||
|
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-width="80px">
|
||||||
|
<el-form-item label="旧密码" prop="old_password">
|
||||||
|
<el-input v-model="passwordForm.old_password" type="password" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="新密码" prop="new_password">
|
||||||
|
<el-input v-model="passwordForm.new_password" type="password" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="passwordDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="passwordLoading" @click="handleChangePassword">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
Odometer, OfficeBuilding, Document, List,
|
||||||
|
Fold, Expand, UserFilled, ArrowDown
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const isCollapsed = ref(false)
|
||||||
|
const activeMenu = computed(() => route.path)
|
||||||
|
|
||||||
|
// 修改密码
|
||||||
|
const passwordDialogVisible = ref(false)
|
||||||
|
const passwordLoading = ref(false)
|
||||||
|
const passwordFormRef = ref()
|
||||||
|
const passwordForm = reactive({
|
||||||
|
old_password: '',
|
||||||
|
new_password: ''
|
||||||
|
})
|
||||||
|
const passwordRules = {
|
||||||
|
old_password: [{ required: true, message: '请输入旧密码', trigger: 'blur' }],
|
||||||
|
new_password: [
|
||||||
|
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||||
|
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCommand(command) {
|
||||||
|
if (command === 'logout') {
|
||||||
|
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||||
|
type: 'warning'
|
||||||
|
}).then(() => {
|
||||||
|
authStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}).catch(() => {})
|
||||||
|
} else if (command === 'password') {
|
||||||
|
passwordForm.old_password = ''
|
||||||
|
passwordForm.new_password = ''
|
||||||
|
passwordDialogVisible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChangePassword() {
|
||||||
|
await passwordFormRef.value.validate()
|
||||||
|
|
||||||
|
passwordLoading.value = true
|
||||||
|
try {
|
||||||
|
await api.auth.changePassword(passwordForm)
|
||||||
|
ElMessage.success('密码修改成功')
|
||||||
|
passwordDialogVisible.value = false
|
||||||
|
} finally {
|
||||||
|
passwordLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.layout-container {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-aside {
|
||||||
|
background: #1a1a2e;
|
||||||
|
transition: width 0.3s;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-menu {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-header {
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.collapse-btn {
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.username {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-main {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
157
admin-frontend/src/views/Login.vue
Normal file
157
admin-frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<h1>考培练系统</h1>
|
||||||
|
<p>SaaS 超级管理后台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
class="login-form"
|
||||||
|
@submit.prevent="handleLogin"
|
||||||
|
>
|
||||||
|
<el-form-item prop="username">
|
||||||
|
<el-input
|
||||||
|
v-model="form.username"
|
||||||
|
placeholder="用户名"
|
||||||
|
:prefix-icon="User"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="密码"
|
||||||
|
:prefix-icon="Lock"
|
||||||
|
size="large"
|
||||||
|
show-password
|
||||||
|
@keyup.enter="handleLogin"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
:loading="loading"
|
||||||
|
class="login-btn"
|
||||||
|
@click="handleLogin"
|
||||||
|
>
|
||||||
|
登 录
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="login-footer">
|
||||||
|
<p>© 2026 考培练系统 - 艾博智科技</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { User, Lock } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const formRef = ref()
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||||
|
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
await formRef.value.validate()
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await authStore.login(form.username, form.password)
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
|
||||||
|
const redirect = route.query.redirect || '/dashboard'
|
||||||
|
router.push(redirect)
|
||||||
|
} catch (e) {
|
||||||
|
// 错误已在拦截器处理
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.login-container {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 400px;
|
||||||
|
padding: 40px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
.el-form-item {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
178
admin-frontend/src/views/Logs.vue
Normal file
178
admin-frontend/src/views/Logs.vue
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<div class="logs-page">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>操作日志</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-select v-model="filters.operation_type" placeholder="操作类型" clearable style="width: 150px;">
|
||||||
|
<el-option label="创建" value="create" />
|
||||||
|
<el-option label="更新" value="update" />
|
||||||
|
<el-option label="删除" value="delete" />
|
||||||
|
<el-option label="启用" value="enable" />
|
||||||
|
<el-option label="禁用" value="disable" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-select v-model="filters.resource_type" placeholder="资源类型" clearable style="width: 150px;">
|
||||||
|
<el-option label="租户" value="tenant" />
|
||||||
|
<el-option label="配置" value="config" />
|
||||||
|
<el-option label="提示词" value="prompt" />
|
||||||
|
<el-option label="功能开关" value="feature" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-button type="primary" @click="fetchLogs">查询</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="logs" v-loading="loading" style="width: 100%;">
|
||||||
|
<el-table-column prop="created_at" label="时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.created_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="admin_username" label="操作人" width="120" />
|
||||||
|
<el-table-column prop="tenant_code" label="租户" width="100" />
|
||||||
|
<el-table-column prop="operation_type" label="操作" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getOperationType(row.operation_type)" size="small">
|
||||||
|
{{ getOperationLabel(row.operation_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="resource_type" label="资源类型" width="100" />
|
||||||
|
<el-table-column prop="resource_name" label="资源名称" />
|
||||||
|
<el-table-column label="操作" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="showDetail(row)">详情</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<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"
|
||||||
|
@size-change="fetchLogs"
|
||||||
|
@current-change="fetchLogs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 详情对话框 -->
|
||||||
|
<el-dialog v-model="detailVisible" title="操作详情" width="600px">
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="操作时间">{{ formatDate(currentLog?.created_at) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="操作人">{{ currentLog?.admin_username }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="租户">{{ currentLog?.tenant_code || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="操作类型">{{ getOperationLabel(currentLog?.operation_type) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="资源类型">{{ currentLog?.resource_type }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="资源名称">{{ currentLog?.resource_name }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<div v-if="currentLog?.old_value" style="margin-top: 20px;">
|
||||||
|
<h4>变更前</h4>
|
||||||
|
<pre class="json-view">{{ JSON.stringify(currentLog.old_value, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentLog?.new_value" style="margin-top: 20px;">
|
||||||
|
<h4>变更后</h4>
|
||||||
|
<pre class="json-view">{{ JSON.stringify(currentLog.new_value, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const logs = ref([])
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
operation_type: '',
|
||||||
|
resource_type: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const currentLog = ref(null)
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOperationType(type) {
|
||||||
|
const map = {
|
||||||
|
create: 'success',
|
||||||
|
update: 'warning',
|
||||||
|
delete: 'danger',
|
||||||
|
enable: 'success',
|
||||||
|
disable: 'info'
|
||||||
|
}
|
||||||
|
return map[type] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOperationLabel(type) {
|
||||||
|
const map = {
|
||||||
|
create: '创建',
|
||||||
|
update: '更新',
|
||||||
|
delete: '删除',
|
||||||
|
enable: '启用',
|
||||||
|
disable: '禁用',
|
||||||
|
batch_update: '批量更新',
|
||||||
|
rollback: '回滚',
|
||||||
|
reset: '重置'
|
||||||
|
}
|
||||||
|
return map[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLogs() {
|
||||||
|
// TODO: 调用 API 获取日志
|
||||||
|
// 暂时使用模拟数据
|
||||||
|
logs.value = []
|
||||||
|
total.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetail(log) {
|
||||||
|
currentLog.value = log
|
||||||
|
detailVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchLogs()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.logs-page {
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-view {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
31
admin-frontend/src/views/NotFound.vue
Normal file
31
admin-frontend/src/views/NotFound.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div class="not-found">
|
||||||
|
<h1>404</h1>
|
||||||
|
<p>页面不存在</p>
|
||||||
|
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.not-found {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f7fa;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 120px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #666;
|
||||||
|
margin: 20px 0 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
179
admin-frontend/src/views/prompts/PromptDetail.vue
Normal file
179
admin-frontend/src/views/prompts/PromptDetail.vue
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prompt-detail" v-loading="loading">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>提示词详情 - {{ prompt.name }}</span>
|
||||||
|
<div>
|
||||||
|
<el-button @click="$router.back()">返回</el-button>
|
||||||
|
<el-button type="primary" @click="isEditing = !isEditing">
|
||||||
|
{{ isEditing ? '取消编辑' : '编辑' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form ref="formRef" :model="form" label-width="120px" :disabled="!isEditing">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="编码">
|
||||||
|
<el-input v-model="form.code" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="版本">
|
||||||
|
<el-tag>v{{ prompt.version }}</el-tag>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="名称" prop="name">
|
||||||
|
<el-input v-model="form.name" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="模块">
|
||||||
|
<el-input v-model="form.module" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item label="说明" prop="description">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="变量">
|
||||||
|
<el-tag v-for="v in prompt.variables" :key="v" style="margin-right: 8px;">
|
||||||
|
{{ v }}
|
||||||
|
</el-tag>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="系统提示词" prop="system_prompt">
|
||||||
|
<el-input
|
||||||
|
v-model="form.system_prompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="15"
|
||||||
|
:autosize="{ minRows: 10, maxRows: 30 }"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="用户提示词模板" prop="user_prompt_template">
|
||||||
|
<el-input
|
||||||
|
v-model="form.user_prompt_template"
|
||||||
|
type="textarea"
|
||||||
|
:rows="8"
|
||||||
|
:autosize="{ minRows: 5, maxRows: 15 }"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="推荐模型">
|
||||||
|
<el-input v-model="form.model_recommendation" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="最大Token">
|
||||||
|
<el-input-number v-model="form.max_tokens" :min="100" :max="32000" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="温度">
|
||||||
|
<el-input-number v-model="form.temperature" :min="0" :max="2" :step="0.1" :precision="1" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item v-if="isEditing">
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const promptId = route.params.id
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const formRef = ref()
|
||||||
|
|
||||||
|
const prompt = ref({})
|
||||||
|
const form = reactive({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
module: '',
|
||||||
|
system_prompt: '',
|
||||||
|
user_prompt_template: '',
|
||||||
|
model_recommendation: '',
|
||||||
|
max_tokens: 4096,
|
||||||
|
temperature: 0.7
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchPrompt() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.prompts.get(promptId)
|
||||||
|
prompt.value = res
|
||||||
|
Object.assign(form, {
|
||||||
|
code: res.code,
|
||||||
|
name: res.name,
|
||||||
|
description: res.description,
|
||||||
|
module: res.module,
|
||||||
|
system_prompt: res.system_prompt,
|
||||||
|
user_prompt_template: res.user_prompt_template,
|
||||||
|
model_recommendation: res.model_recommendation,
|
||||||
|
max_tokens: res.max_tokens,
|
||||||
|
temperature: res.temperature
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await api.prompts.update(promptId, {
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
system_prompt: form.system_prompt,
|
||||||
|
user_prompt_template: form.user_prompt_template,
|
||||||
|
model_recommendation: form.model_recommendation,
|
||||||
|
max_tokens: form.max_tokens,
|
||||||
|
temperature: form.temperature
|
||||||
|
})
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
isEditing.value = false
|
||||||
|
fetchPrompt()
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchPrompt()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.prompt-detail {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
159
admin-frontend/src/views/prompts/PromptList.vue
Normal file
159
admin-frontend/src/views/prompts/PromptList.vue
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prompt-list">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>提示词管理</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-select v-model="moduleFilter" placeholder="模块" clearable style="width: 150px;">
|
||||||
|
<el-option label="课程模块" value="course" />
|
||||||
|
<el-option label="考试模块" value="exam" />
|
||||||
|
<el-option label="陪练模块" value="practice" />
|
||||||
|
<el-option label="能力评估" value="ability" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-button type="primary" @click="fetchPrompts">查询</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="prompts" v-loading="loading" style="width: 100%;">
|
||||||
|
<el-table-column prop="code" label="编码" width="180" />
|
||||||
|
<el-table-column prop="name" label="名称" width="150" />
|
||||||
|
<el-table-column prop="module" label="模块" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getModuleLabel(row.module) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="description" label="说明" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="version" label="版本" width="80" />
|
||||||
|
<el-table-column prop="is_active" label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
|
||||||
|
{{ row.is_active ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="$router.push(`/prompts/${row.id}`)">
|
||||||
|
查看
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" link @click="showVersions(row)">
|
||||||
|
历史
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 版本历史对话框 -->
|
||||||
|
<el-dialog v-model="versionDialogVisible" title="版本历史" width="800px">
|
||||||
|
<el-table :data="versions" v-loading="versionLoading">
|
||||||
|
<el-table-column prop="version" label="版本" width="80" />
|
||||||
|
<el-table-column prop="change_summary" label="变更说明" />
|
||||||
|
<el-table-column prop="created_at" label="时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.created_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="handleRollback(row)">回滚</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const prompts = ref([])
|
||||||
|
const moduleFilter = ref('')
|
||||||
|
|
||||||
|
const versionDialogVisible = ref(false)
|
||||||
|
const versionLoading = ref(false)
|
||||||
|
const versions = ref([])
|
||||||
|
const currentPromptId = ref(null)
|
||||||
|
|
||||||
|
function getModuleLabel(module) {
|
||||||
|
const map = {
|
||||||
|
course: '课程',
|
||||||
|
exam: '考试',
|
||||||
|
practice: '陪练',
|
||||||
|
ability: '能力'
|
||||||
|
}
|
||||||
|
return map[module] || module
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPrompts() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.prompts.list({
|
||||||
|
module: moduleFilter.value || undefined
|
||||||
|
})
|
||||||
|
prompts.value = res
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showVersions(prompt) {
|
||||||
|
currentPromptId.value = prompt.id
|
||||||
|
versionDialogVisible.value = true
|
||||||
|
|
||||||
|
versionLoading.value = true
|
||||||
|
try {
|
||||||
|
versions.value = await api.prompts.getVersions(prompt.id)
|
||||||
|
} finally {
|
||||||
|
versionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRollback(version) {
|
||||||
|
await ElMessageBox.confirm(`确定要回滚到版本 ${version.version} 吗?`, '提示', {
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.prompts.rollback(currentPromptId.value, version.version)
|
||||||
|
ElMessage.success('回滚成功')
|
||||||
|
versionDialogVisible.value = false
|
||||||
|
fetchPrompts()
|
||||||
|
} catch (e) {
|
||||||
|
// 错误已在拦截器处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchPrompts()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.prompt-list {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
142
admin-frontend/src/views/tenants/TenantConfigs.vue
Normal file
142
admin-frontend/src/views/tenants/TenantConfigs.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tenant-configs" v-loading="loading">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>租户配置 - {{ tenantName }}</span>
|
||||||
|
<div>
|
||||||
|
<el-button @click="$router.back()">返回</el-button>
|
||||||
|
<el-button type="primary" @click="handleSaveAll" :loading="saving">保存全部</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab" type="border-card">
|
||||||
|
<el-tab-pane
|
||||||
|
v-for="group in configGroups"
|
||||||
|
:key="group.group_name"
|
||||||
|
:label="group.group_display_name"
|
||||||
|
:name="group.group_name"
|
||||||
|
>
|
||||||
|
<el-form label-width="180px">
|
||||||
|
<el-form-item
|
||||||
|
v-for="config in group.configs"
|
||||||
|
:key="config.config_key"
|
||||||
|
:label="config.display_name || config.config_key"
|
||||||
|
>
|
||||||
|
<template v-if="config.is_secret">
|
||||||
|
<el-input
|
||||||
|
v-model="configValues[`${config.config_group}.${config.config_key}`]"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
:placeholder="config.description"
|
||||||
|
style="width: 400px;"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-input
|
||||||
|
v-model="configValues[`${config.config_group}.${config.config_key}`]"
|
||||||
|
:placeholder="config.description"
|
||||||
|
style="width: 400px;"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<span class="config-desc" v-if="config.description">{{ config.description }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const tenantId = route.params.id
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const tenantName = ref('')
|
||||||
|
const activeTab = ref('database')
|
||||||
|
const configGroups = ref([])
|
||||||
|
const configValues = reactive({})
|
||||||
|
|
||||||
|
async function fetchConfigs() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// 获取租户信息
|
||||||
|
const tenant = await api.tenants.get(tenantId)
|
||||||
|
tenantName.value = tenant.name
|
||||||
|
|
||||||
|
// 获取配置
|
||||||
|
const groups = await api.configs.getTenantConfigs(tenantId)
|
||||||
|
configGroups.value = groups
|
||||||
|
|
||||||
|
// 填充配置值
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const config of group.configs) {
|
||||||
|
const key = `${config.config_group}.${config.config_key}`
|
||||||
|
configValues[key] = config.config_value || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveAll() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const configs = []
|
||||||
|
|
||||||
|
for (const group of configGroups.value) {
|
||||||
|
for (const config of group.configs) {
|
||||||
|
const key = `${config.config_group}.${config.config_key}`
|
||||||
|
const value = configValues[key]
|
||||||
|
|
||||||
|
// 只保存有值的配置
|
||||||
|
if (value) {
|
||||||
|
configs.push({
|
||||||
|
config_group: config.config_group,
|
||||||
|
config_key: config.config_key,
|
||||||
|
config_value: value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.configs.batchUpdate(tenantId, { configs })
|
||||||
|
ElMessage.success('配置保存成功')
|
||||||
|
|
||||||
|
// 刷新缓存
|
||||||
|
await api.configs.refreshCache(tenantId)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchConfigs()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.tenant-configs {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
217
admin-frontend/src/views/tenants/TenantDetail.vue
Normal file
217
admin-frontend/src/views/tenants/TenantDetail.vue
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tenant-detail" v-loading="loading">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>租户详情</span>
|
||||||
|
<div>
|
||||||
|
<el-button @click="$router.back()">返回</el-button>
|
||||||
|
<el-button type="primary" @click="isEditing = !isEditing">
|
||||||
|
{{ isEditing ? '取消编辑' : '编辑' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
label-width="120px"
|
||||||
|
:disabled="!isEditing"
|
||||||
|
>
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="租户编码">
|
||||||
|
<el-input v-model="form.code" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-tag :type="form.status === 'active' ? 'success' : 'danger'">
|
||||||
|
{{ form.status === 'active' ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="租户名称" prop="name">
|
||||||
|
<el-input v-model="form.name" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="显示名称" prop="display_name">
|
||||||
|
<el-input v-model="form.display_name" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="域名" prop="domain">
|
||||||
|
<el-input v-model="form.domain" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="行业" prop="industry">
|
||||||
|
<el-select v-model="form.industry" style="width: 100%;">
|
||||||
|
<el-option label="轻医美" value="medical_beauty" />
|
||||||
|
<el-option label="宠物" value="pet" />
|
||||||
|
<el-option label="教育" value="education" />
|
||||||
|
<el-option label="其他" value="other" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="联系人" prop="contact_name">
|
||||||
|
<el-input v-model="form.contact_name" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="联系电话" prop="contact_phone">
|
||||||
|
<el-input v-model="form.contact_phone" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="联系邮箱" prop="contact_email">
|
||||||
|
<el-input v-model="form.contact_email" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="服务到期">
|
||||||
|
<el-date-picker v-model="form.expire_at" type="date" style="width: 100%;" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item label="备注" prop="remarks">
|
||||||
|
<el-input v-model="form.remarks" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="isEditing">
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 快捷操作 -->
|
||||||
|
<el-card shadow="hover" style="margin-top: 20px;">
|
||||||
|
<template #header>
|
||||||
|
<span>快捷操作</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="quick-actions">
|
||||||
|
<el-button @click="$router.push(`/tenants/${tenantId}/configs`)">
|
||||||
|
<el-icon><Setting /></el-icon>
|
||||||
|
配置管理
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="$router.push(`/tenants/${tenantId}/features`)">
|
||||||
|
<el-icon><Switch /></el-icon>
|
||||||
|
功能开关
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Setting, Switch } from '@element-plus/icons-vue'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const tenantId = route.params.id
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const formRef = ref()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
display_name: '',
|
||||||
|
domain: '',
|
||||||
|
industry: '',
|
||||||
|
contact_name: '',
|
||||||
|
contact_phone: '',
|
||||||
|
contact_email: '',
|
||||||
|
expire_at: null,
|
||||||
|
remarks: '',
|
||||||
|
status: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
name: [{ required: true, message: '请输入租户名称', trigger: 'blur' }],
|
||||||
|
domain: [{ required: true, message: '请输入域名', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTenant() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.tenants.get(tenantId)
|
||||||
|
Object.assign(form, res)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
await formRef.value.validate()
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await api.tenants.update(tenantId, {
|
||||||
|
name: form.name,
|
||||||
|
display_name: form.display_name,
|
||||||
|
domain: form.domain,
|
||||||
|
industry: form.industry,
|
||||||
|
contact_name: form.contact_name,
|
||||||
|
contact_phone: form.contact_phone,
|
||||||
|
contact_email: form.contact_email,
|
||||||
|
expire_at: form.expire_at,
|
||||||
|
remarks: form.remarks
|
||||||
|
})
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
isEditing.value = false
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTenant()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.tenant-detail {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
129
admin-frontend/src/views/tenants/TenantFeatures.vue
Normal file
129
admin-frontend/src/views/tenants/TenantFeatures.vue
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tenant-features" v-loading="loading">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>功能开关 - {{ tenantName }}</span>
|
||||||
|
<el-button @click="$router.back()">返回</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-for="group in featureGroups" :key="group.group_name" class="feature-group">
|
||||||
|
<h3>{{ group.group_display_name }}</h3>
|
||||||
|
|
||||||
|
<el-table :data="group.features" style="width: 100%;">
|
||||||
|
<el-table-column prop="feature_name" label="功能名称" width="200" />
|
||||||
|
<el-table-column prop="description" label="说明" />
|
||||||
|
<el-table-column label="状态" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
v-model="row.is_enabled"
|
||||||
|
@change="handleToggle(row)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="handleReset(row)"
|
||||||
|
v-if="row.tenant_id"
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
<span v-else class="default-label">默认</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const tenantId = route.params.id
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const tenantName = ref('')
|
||||||
|
const featureGroups = ref([])
|
||||||
|
|
||||||
|
async function fetchFeatures() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// 获取租户信息
|
||||||
|
const tenant = await api.tenants.get(tenantId)
|
||||||
|
tenantName.value = tenant.name
|
||||||
|
|
||||||
|
// 获取功能开关
|
||||||
|
const groups = await api.features.getTenantFeatures(tenantId)
|
||||||
|
featureGroups.value = groups
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggle(feature) {
|
||||||
|
try {
|
||||||
|
await api.features.updateFeature(tenantId, feature.feature_code, {
|
||||||
|
is_enabled: feature.is_enabled
|
||||||
|
})
|
||||||
|
ElMessage.success(feature.is_enabled ? '功能已启用' : '功能已禁用')
|
||||||
|
} catch (e) {
|
||||||
|
// 回滚状态
|
||||||
|
feature.is_enabled = !feature.is_enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset(feature) {
|
||||||
|
await ElMessageBox.confirm('确定要重置为默认配置吗?', '提示', {
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.features.resetFeature(tenantId, feature.feature_code)
|
||||||
|
ElMessage.success('已重置为默认配置')
|
||||||
|
fetchFeatures()
|
||||||
|
} catch (e) {
|
||||||
|
// 错误已在拦截器处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchFeatures()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.tenant-features {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-group {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-label {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
250
admin-frontend/src/views/tenants/TenantList.vue
Normal file
250
admin-frontend/src/views/tenants/TenantList.vue
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tenant-list">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>租户管理</span>
|
||||||
|
<el-button type="primary" @click="showCreateDialog">
|
||||||
|
<el-icon><Plus /></el-icon> 新建租户
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="搜索租户名称/编码/域名"
|
||||||
|
style="width: 300px;"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="fetchTenants"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
|
||||||
|
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 120px;">
|
||||||
|
<el-option label="启用" value="active" />
|
||||||
|
<el-option label="禁用" value="inactive" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-button type="primary" @click="fetchTenants">查询</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="tenants" v-loading="loading" style="width: 100%;">
|
||||||
|
<el-table-column prop="code" label="编码" width="100" />
|
||||||
|
<el-table-column prop="name" label="名称" width="150" />
|
||||||
|
<el-table-column prop="domain" label="域名" />
|
||||||
|
<el-table-column prop="industry" label="行业" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getIndustryLabel(row.industry) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
|
||||||
|
{{ row.status === 'active' ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="config_count" label="配置项" width="80" />
|
||||||
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="$router.push(`/tenants/${row.id}`)">详情</el-button>
|
||||||
|
<el-button type="primary" link @click="$router.push(`/tenants/${row.id}/configs`)">配置</el-button>
|
||||||
|
<el-button type="primary" link @click="$router.push(`/tenants/${row.id}/features`)">功能</el-button>
|
||||||
|
<el-button
|
||||||
|
:type="row.status === 'active' ? 'warning' : 'success'"
|
||||||
|
link
|
||||||
|
@click="toggleStatus(row)"
|
||||||
|
>
|
||||||
|
{{ row.status === 'active' ? '禁用' : '启用' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@size-change="fetchTenants"
|
||||||
|
@current-change="fetchTenants"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 新建租户对话框 -->
|
||||||
|
<el-dialog v-model="createDialogVisible" title="新建租户" width="500px">
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||||
|
<el-form-item label="租户编码" prop="code">
|
||||||
|
<el-input v-model="form.code" placeholder="英文小写,如:hua" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="租户名称" prop="name">
|
||||||
|
<el-input v-model="form.name" placeholder="如:华尔倍丽" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="域名" prop="domain">
|
||||||
|
<el-input v-model="form.domain" placeholder="如:hua.ireborn.com.cn" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="行业" prop="industry">
|
||||||
|
<el-select v-model="form.industry" style="width: 100%;">
|
||||||
|
<el-option label="轻医美" value="medical_beauty" />
|
||||||
|
<el-option label="宠物" value="pet" />
|
||||||
|
<el-option label="教育" value="education" />
|
||||||
|
<el-option label="其他" value="other" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="联系人" prop="contact_name">
|
||||||
|
<el-input v-model="form.contact_name" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="联系电话" prop="contact_phone">
|
||||||
|
<el-input v-model="form.contact_phone" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleCreate">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Search } from '@element-plus/icons-vue'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const tenants = ref([])
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
const keyword = ref('')
|
||||||
|
const statusFilter = ref('')
|
||||||
|
|
||||||
|
const createDialogVisible = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const formRef = ref()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
domain: '',
|
||||||
|
industry: 'medical_beauty',
|
||||||
|
contact_name: '',
|
||||||
|
contact_phone: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
code: [
|
||||||
|
{ required: true, message: '请输入租户编码', trigger: 'blur' },
|
||||||
|
{ pattern: /^[a-z0-9_]+$/, message: '只能包含小写字母、数字和下划线', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
name: [{ required: true, message: '请输入租户名称', trigger: 'blur' }],
|
||||||
|
domain: [{ required: true, message: '请输入域名', trigger: 'blur' }],
|
||||||
|
industry: [{ required: true, message: '请选择行业', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIndustryLabel(industry) {
|
||||||
|
const map = {
|
||||||
|
medical_beauty: '轻医美',
|
||||||
|
pet: '宠物',
|
||||||
|
education: '教育',
|
||||||
|
other: '其他'
|
||||||
|
}
|
||||||
|
return map[industry] || industry
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTenants() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.tenants.list({
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
keyword: keyword.value || undefined,
|
||||||
|
status: statusFilter.value || undefined
|
||||||
|
})
|
||||||
|
tenants.value = res.items
|
||||||
|
total.value = res.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateDialog() {
|
||||||
|
Object.assign(form, {
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
domain: '',
|
||||||
|
industry: 'medical_beauty',
|
||||||
|
contact_name: '',
|
||||||
|
contact_phone: ''
|
||||||
|
})
|
||||||
|
createDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
await formRef.value.validate()
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await api.tenants.create(form)
|
||||||
|
ElMessage.success('租户创建成功')
|
||||||
|
createDialogVisible.value = false
|
||||||
|
fetchTenants()
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(tenant) {
|
||||||
|
const action = tenant.status === 'active' ? '禁用' : '启用'
|
||||||
|
|
||||||
|
await ElMessageBox.confirm(`确定要${action}租户 ${tenant.name} 吗?`, '提示', {
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (tenant.status === 'active') {
|
||||||
|
await api.tenants.disable(tenant.id)
|
||||||
|
} else {
|
||||||
|
await api.tenants.enable(tenant.id)
|
||||||
|
}
|
||||||
|
ElMessage.success(`${action}成功`)
|
||||||
|
fetchTenants()
|
||||||
|
} catch (e) {
|
||||||
|
// 错误已在拦截器处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTenants()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.tenant-list {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
16
admin-frontend/tsconfig.app.json
Normal file
16
admin-frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
8
admin-frontend/tsconfig.json
Normal file
8
admin-frontend/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.app.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
19
admin-frontend/tsconfig.node.json
Normal file
19
admin-frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node20/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"nightwatch.conf.*",
|
||||||
|
"playwright.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
39
admin-frontend/vite.config.ts
Normal file
39
admin-frontend/vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
AutoImport({
|
||||||
|
resolvers: [ElementPlusResolver()],
|
||||||
|
imports: ['vue', 'vue-router', 'pinia'],
|
||||||
|
}),
|
||||||
|
Components({
|
||||||
|
resolvers: [ElementPlusResolver()],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 3030,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8030',
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
74
backend/.env.ex
Normal file
74
backend/.env.ex
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# 恩喜成都总院生产环境配置
|
||||||
|
APP_NAME="恩喜成都总院-考培练系统"
|
||||||
|
APP_VERSION="1.0.0"
|
||||||
|
DEBUG=false
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
|
||||||
|
# 数据库配置 - 共享MySQL实例
|
||||||
|
DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_ex?charset=utf8mb4
|
||||||
|
MYSQL_HOST=prod-mysql
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=root
|
||||||
|
MYSQL_PASSWORD=ProdMySQL2025!@#
|
||||||
|
MYSQL_DATABASE=kaopeilian_ex
|
||||||
|
|
||||||
|
# Redis配置
|
||||||
|
REDIS_URL=redis://ex-redis:6379/0
|
||||||
|
REDIS_HOST=ex-redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# 安全配置
|
||||||
|
SECRET_KEY=ex_8f7a9c3e1b4d6f2a5c8e7b9d1f3a6c4e8b2d5f7a9c1e3b6d8f2a4c7e9b1d3f5a
|
||||||
|
ALGORITHM=HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
# CORS配置
|
||||||
|
CORS_ORIGINS=["https://ex.ireborn.com.cn", "http://ex.ireborn.com.cn"]
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FORMAT=json
|
||||||
|
|
||||||
|
# 文件上传配置
|
||||||
|
UPLOAD_MAX_SIZE=10485760
|
||||||
|
UPLOAD_ALLOWED_TYPES=["image/jpeg", "image/png", "application/pdf", "audio/mpeg", "audio/wav", "audio/webm"]
|
||||||
|
UPLOAD_DIR=uploads
|
||||||
|
|
||||||
|
# Coze OAuth配置
|
||||||
|
COZE_OAUTH_CLIENT_ID=1114009328887
|
||||||
|
COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I
|
||||||
|
COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem
|
||||||
|
COZE_PRACTICE_BOT_ID=7560643598174683145
|
||||||
|
|
||||||
|
# Dify 工作流 API Key 配置
|
||||||
|
# 01-知识点分析
|
||||||
|
# 02-试题生成器
|
||||||
|
# 03-陪练知识准备
|
||||||
|
# 04-与课程对话
|
||||||
|
# 05-智能工牌能力分析与课程推荐
|
||||||
|
|
||||||
|
# Coze 播课配置
|
||||||
|
COZE_BROADCAST_WORKFLOW_ID=7577978749833838602
|
||||||
|
COZE_BROADCAST_SPACE_ID=7474971491470688296
|
||||||
|
COZE_BROADCAST_BOT_ID=7560643598174683145
|
||||||
|
|
||||||
|
# AI 服务配置(知识点分析 V2 - 测试阶段 Key)
|
||||||
|
AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw
|
||||||
|
AI_PRIMARY_BASE_URL=https://4sapi.com/v1
|
||||||
|
AI_FALLBACK_API_KEY=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
|
||||||
|
AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
AI_DEFAULT_MODEL=gemini-3-flash-preview
|
||||||
|
AI_TIMEOUT=120
|
||||||
|
|
||||||
|
# 租户配置(用于多租户部署)
|
||||||
|
TENANT_CODE=ex
|
||||||
|
|
||||||
|
# 管理库连接配置(用于从 tenant_configs 表读取配置)
|
||||||
|
ADMIN_DB_HOST=prod-mysql
|
||||||
|
ADMIN_DB_PORT=3306
|
||||||
|
ADMIN_DB_USER=root
|
||||||
|
ADMIN_DB_PASSWORD=ProdMySQL2025!@#
|
||||||
|
ADMIN_DB_NAME=kaopeilian_admin
|
||||||
8
backend/.env.example
Normal file
8
backend/.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# 开发环境配置示例
|
||||||
|
DATABASE_URL=mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
DEBUG=true
|
||||||
|
SECRET_KEY=kaopeilian-secret-key-dev
|
||||||
|
CORS_ORIGINS=["http://localhost:3001","http://localhost:3000"]
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
69
backend/.env.fw
Normal file
69
backend/.env.fw
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# 飞沃生产环境配置
|
||||||
|
APP_NAME="飞沃-考培练系统"
|
||||||
|
APP_VERSION="1.0.0"
|
||||||
|
DEBUG=false
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
|
||||||
|
# 数据库配置 - 共享MySQL实例
|
||||||
|
DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_fw?charset=utf8mb4
|
||||||
|
MYSQL_HOST=prod-mysql
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=root
|
||||||
|
MYSQL_PASSWORD=ProdMySQL2025!@#
|
||||||
|
MYSQL_DATABASE=kaopeilian_fw
|
||||||
|
|
||||||
|
# Redis配置
|
||||||
|
REDIS_URL=redis://fw-redis:6379/0
|
||||||
|
REDIS_HOST=fw-redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# 安全配置
|
||||||
|
SECRET_KEY=fw_00e0e0e6i5h28g6g2f7fhi46f1e6i6f2f1h22f5i1h5g8j2h3e6g0i5j8fd1g7h
|
||||||
|
ALGORITHM=HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
# CORS配置
|
||||||
|
CORS_ORIGINS=["https://fw.ireborn.com.cn", "http://fw.ireborn.com.cn"]
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FORMAT=json
|
||||||
|
|
||||||
|
# 文件上传配置
|
||||||
|
UPLOAD_MAX_SIZE=10485760
|
||||||
|
UPLOAD_ALLOWED_TYPES=["image/jpeg", "image/png", "application/pdf", "audio/mpeg", "audio/wav", "audio/webm"]
|
||||||
|
UPLOAD_DIR=uploads
|
||||||
|
|
||||||
|
# Coze OAuth配置
|
||||||
|
COZE_OAUTH_CLIENT_ID=1114009328887
|
||||||
|
COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I
|
||||||
|
COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem
|
||||||
|
COZE_PRACTICE_BOT_ID=7560643598174683145
|
||||||
|
|
||||||
|
# Dify 工作流 API Key 配置
|
||||||
|
|
||||||
|
# Coze 播课配置
|
||||||
|
COZE_BROADCAST_WORKFLOW_ID=7577980956000534578
|
||||||
|
COZE_BROADCAST_SPACE_ID=7474971491470688296
|
||||||
|
COZE_BROADCAST_BOT_ID=7560643598174683145
|
||||||
|
|
||||||
|
# AI 服务配置(知识点分析 V2 - 测试阶段 Key)
|
||||||
|
AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw
|
||||||
|
AI_PRIMARY_BASE_URL=https://4sapi.com/v1
|
||||||
|
AI_FALLBACK_API_KEY=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
|
||||||
|
AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
AI_DEFAULT_MODEL=gemini-3-flash-preview
|
||||||
|
AI_TIMEOUT=120
|
||||||
|
|
||||||
|
# 租户配置(用于多租户部署)
|
||||||
|
TENANT_CODE=fw
|
||||||
|
|
||||||
|
# 管理库连接配置(用于从 tenant_configs 表读取配置)
|
||||||
|
ADMIN_DB_HOST=prod-mysql
|
||||||
|
ADMIN_DB_PORT=3306
|
||||||
|
ADMIN_DB_USER=root
|
||||||
|
ADMIN_DB_PASSWORD=ProdMySQL2025!@#
|
||||||
|
ADMIN_DB_NAME=kaopeilian_admin
|
||||||
69
backend/.env.hl
Normal file
69
backend/.env.hl
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# 武汉禾丽生产环境配置
|
||||||
|
APP_NAME="武汉禾丽-考培练系统"
|
||||||
|
APP_VERSION="1.0.0"
|
||||||
|
DEBUG=false
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
|
||||||
|
# 数据库配置 - 共享MySQL实例
|
||||||
|
DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_hl?charset=utf8mb4
|
||||||
|
MYSQL_HOST=prod-mysql
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=root
|
||||||
|
MYSQL_PASSWORD=ProdMySQL2025!@#
|
||||||
|
MYSQL_DATABASE=kaopeilian_hl
|
||||||
|
|
||||||
|
# Redis配置
|
||||||
|
REDIS_URL=redis://hl-redis:6379/0
|
||||||
|
REDIS_HOST=hl-redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# 安全配置
|
||||||
|
SECRET_KEY=hl_88c8c8c4g3f06e4e0d5fdg24d9c4g4d0d9f00d3g9f3e6h0f1c4e8g3h6db9e5f
|
||||||
|
ALGORITHM=HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
# CORS配置
|
||||||
|
CORS_ORIGINS=["https://hl.ireborn.com.cn", "http://hl.ireborn.com.cn"]
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FORMAT=json
|
||||||
|
|
||||||
|
# 文件上传配置
|
||||||
|
UPLOAD_MAX_SIZE=10485760
|
||||||
|
UPLOAD_ALLOWED_TYPES=["image/jpeg", "image/png", "application/pdf", "audio/mpeg", "audio/wav", "audio/webm"]
|
||||||
|
UPLOAD_DIR=uploads
|
||||||
|
|
||||||
|
# Coze OAuth配置
|
||||||
|
COZE_OAUTH_CLIENT_ID=1114009328887
|
||||||
|
COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I
|
||||||
|
COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem
|
||||||
|
COZE_PRACTICE_BOT_ID=7560643598174683145
|
||||||
|
|
||||||
|
# Dify 工作流 API Key 配置
|
||||||
|
|
||||||
|
# Coze 播课配置
|
||||||
|
COZE_BROADCAST_WORKFLOW_ID=7577981581995409450
|
||||||
|
COZE_BROADCAST_SPACE_ID=7474971491470688296
|
||||||
|
COZE_BROADCAST_BOT_ID=7560643598174683145
|
||||||
|
|
||||||
|
# AI 服务配置(知识点分析 V2 - 测试阶段 Key)
|
||||||
|
AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw
|
||||||
|
AI_PRIMARY_BASE_URL=https://4sapi.com/v1
|
||||||
|
AI_FALLBACK_API_KEY=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
|
||||||
|
AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
AI_DEFAULT_MODEL=gemini-3-flash-preview
|
||||||
|
AI_TIMEOUT=120
|
||||||
|
|
||||||
|
# 租户配置(用于多租户部署)
|
||||||
|
TENANT_CODE=hl
|
||||||
|
|
||||||
|
# 管理库连接配置(用于从 tenant_configs 表读取配置)
|
||||||
|
ADMIN_DB_HOST=prod-mysql
|
||||||
|
ADMIN_DB_PORT=3306
|
||||||
|
ADMIN_DB_USER=root
|
||||||
|
ADMIN_DB_PASSWORD=ProdMySQL2025!@#
|
||||||
|
ADMIN_DB_NAME=kaopeilian_admin
|
||||||
69
backend/.env.hua
Normal file
69
backend/.env.hua
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# 华尔倍丽生产环境配置
|
||||||
|
APP_NAME="华尔倍丽-考培练系统"
|
||||||
|
APP_VERSION="1.0.0"
|
||||||
|
DEBUG=false
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
|
||||||
|
# 数据库配置 - 共享MySQL实例
|
||||||
|
DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_hua?charset=utf8mb4
|
||||||
|
MYSQL_HOST=prod-mysql
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=root
|
||||||
|
MYSQL_PASSWORD=ProdMySQL2025!@#
|
||||||
|
MYSQL_DATABASE=kaopeilian_hua
|
||||||
|
|
||||||
|
# Redis配置
|
||||||
|
REDIS_URL=redis://hua-redis:6379/0
|
||||||
|
REDIS_HOST=hua-redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# 安全配置
|
||||||
|
SECRET_KEY=hua_66a6a6a2f1d84c2c8b3dbf02b7a2e2b8b7d88b1e7d1c4f8d9a2c6e1f4b9a7c3d
|
||||||
|
ALGORITHM=HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
# CORS配置
|
||||||
|
CORS_ORIGINS=["https://hua.ireborn.com.cn", "http://hua.ireborn.com.cn"]
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FORMAT=json
|
||||||
|
|
||||||
|
# 文件上传配置
|
||||||
|
UPLOAD_MAX_SIZE=10485760
|
||||||
|
UPLOAD_ALLOWED_TYPES=["image/jpeg", "image/png", "application/pdf", "audio/mpeg", "audio/wav", "audio/webm"]
|
||||||
|
UPLOAD_DIR=uploads
|
||||||
|
|
||||||
|
# Coze OAuth配置
|
||||||
|
COZE_OAUTH_CLIENT_ID=1114009328887
|
||||||
|
COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I
|
||||||
|
COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem
|
||||||
|
COZE_PRACTICE_BOT_ID=7560643598174683145
|
||||||
|
|
||||||
|
# Dify 工作流 API Key 配置
|
||||||
|
|
||||||
|
# Coze 播课配置
|
||||||
|
COZE_BROADCAST_WORKFLOW_ID=7577978749833838602
|
||||||
|
COZE_BROADCAST_SPACE_ID=7474971491470688296
|
||||||
|
COZE_BROADCAST_BOT_ID=7560643598174683145
|
||||||
|
|
||||||
|
# AI 服务配置(知识点分析 V2 - 测试阶段 Key)
|
||||||
|
AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw
|
||||||
|
AI_PRIMARY_BASE_URL=https://4sapi.com/v1
|
||||||
|
AI_FALLBACK_API_KEY=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
|
||||||
|
AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
AI_DEFAULT_MODEL=gemini-3-flash-preview
|
||||||
|
AI_TIMEOUT=120
|
||||||
|
|
||||||
|
# 租户配置(用于多租户部署)
|
||||||
|
TENANT_CODE=hua
|
||||||
|
|
||||||
|
# 管理库连接配置(用于从 tenant_configs 表读取配置)
|
||||||
|
ADMIN_DB_HOST=prod-mysql
|
||||||
|
ADMIN_DB_PORT=3306
|
||||||
|
ADMIN_DB_USER=root
|
||||||
|
ADMIN_DB_PASSWORD=ProdMySQL2025!@#
|
||||||
|
ADMIN_DB_NAME=kaopeilian_admin
|
||||||
68
backend/.env.xy
Normal file
68
backend/.env.xy
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# 芯颜定制生产环境配置
|
||||||
|
APP_NAME="芯颜定制-考培练系统"
|
||||||
|
APP_VERSION="1.0.0"
|
||||||
|
DEBUG=false
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
|
||||||
|
# 数据库配置 - 共享MySQL实例
|
||||||
|
DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_xy?charset=utf8mb4
|
||||||
|
MYSQL_HOST=prod-mysql
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=root
|
||||||
|
MYSQL_PASSWORD=ProdMySQL2025!@#
|
||||||
|
MYSQL_DATABASE=kaopeilian_xy
|
||||||
|
|
||||||
|
# Redis配置
|
||||||
|
REDIS_URL=redis://xy-redis:6379/0
|
||||||
|
REDIS_HOST=xy-redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# 安全配置
|
||||||
|
SECRET_KEY=xy_99d9d9d5h4g17f5f1e6geh35e0d5h5e1e0g11e4h0g4f7i1g2d5f9h4i7ec0f6g
|
||||||
|
ALGORITHM=HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
# CORS配置
|
||||||
|
CORS_ORIGINS=["https://xy.ireborn.com.cn", "http://xy.ireborn.com.cn"]
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FORMAT=json
|
||||||
|
|
||||||
|
# 文件上传配置
|
||||||
|
UPLOAD_MAX_SIZE=10485760
|
||||||
|
UPLOAD_ALLOWED_TYPES=["image/jpeg", "image/png", "application/pdf", "audio/mpeg", "audio/wav", "audio/webm"]
|
||||||
|
UPLOAD_DIR=uploads
|
||||||
|
|
||||||
|
# Coze OAuth配置
|
||||||
|
COZE_OAUTH_CLIENT_ID=1114009328887
|
||||||
|
COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I
|
||||||
|
COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem
|
||||||
|
COZE_PRACTICE_BOT_ID=7560643598174683145
|
||||||
|
# Coze 播课配置
|
||||||
|
COZE_BROADCAST_WORKFLOW_ID=7577968943668084745
|
||||||
|
COZE_BROADCAST_SPACE_ID=7474971491470688296
|
||||||
|
COZE_BROADCAST_BOT_ID=7560643598174683145
|
||||||
|
|
||||||
|
# Dify 工作流 API Key 配置
|
||||||
|
|
||||||
|
# AI 服务配置(知识点分析 V2 - 测试阶段 Key)
|
||||||
|
AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw
|
||||||
|
AI_PRIMARY_BASE_URL=https://4sapi.com/v1
|
||||||
|
AI_FALLBACK_API_KEY=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
|
||||||
|
AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
AI_DEFAULT_MODEL=gemini-3-flash-preview
|
||||||
|
AI_TIMEOUT=120
|
||||||
|
|
||||||
|
# 租户配置(用于多租户部署)
|
||||||
|
TENANT_CODE=xy
|
||||||
|
|
||||||
|
# 管理库连接配置(用于从 tenant_configs 表读取配置)
|
||||||
|
ADMIN_DB_HOST=prod-mysql
|
||||||
|
ADMIN_DB_PORT=3306
|
||||||
|
ADMIN_DB_USER=root
|
||||||
|
ADMIN_DB_PASSWORD=ProdMySQL2025!@#
|
||||||
|
ADMIN_DB_NAME=kaopeilian_admin
|
||||||
69
backend/.env.yy
Normal file
69
backend/.env.yy
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# 杨扬宠物生产环境配置
|
||||||
|
APP_NAME="杨扬宠物-考培练系统"
|
||||||
|
APP_VERSION="1.0.0"
|
||||||
|
DEBUG=false
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
|
||||||
|
# 数据库配置 - 共享MySQL实例
|
||||||
|
DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_yy?charset=utf8mb4
|
||||||
|
MYSQL_HOST=prod-mysql
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=root
|
||||||
|
MYSQL_PASSWORD=ProdMySQL2025!@#
|
||||||
|
MYSQL_DATABASE=kaopeilian_yy
|
||||||
|
|
||||||
|
# Redis配置
|
||||||
|
REDIS_URL=redis://yy-redis:6379/0
|
||||||
|
REDIS_HOST=yy-redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# 安全配置
|
||||||
|
SECRET_KEY=yy_77b7b7b3f2e95d3d9c4ecf13c8b3f3c9c8e99c2f8e2d5g9e0b3d7f2g5ca8d4e
|
||||||
|
ALGORITHM=HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
# CORS配置
|
||||||
|
CORS_ORIGINS=["https://yy.ireborn.com.cn", "http://yy.ireborn.com.cn"]
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FORMAT=json
|
||||||
|
|
||||||
|
# 文件上传配置
|
||||||
|
UPLOAD_MAX_SIZE=10485760
|
||||||
|
UPLOAD_ALLOWED_TYPES=["image/jpeg", "image/png", "application/pdf", "audio/mpeg", "audio/wav", "audio/webm"]
|
||||||
|
UPLOAD_DIR=uploads
|
||||||
|
|
||||||
|
# Coze OAuth配置
|
||||||
|
COZE_OAUTH_CLIENT_ID=1114009328887
|
||||||
|
COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I
|
||||||
|
COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem
|
||||||
|
COZE_PRACTICE_BOT_ID=7560643598174683145
|
||||||
|
|
||||||
|
# Dify 工作流 API Key 配置
|
||||||
|
|
||||||
|
# Coze 播课配置
|
||||||
|
COZE_BROADCAST_WORKFLOW_ID=7577980363517018150
|
||||||
|
COZE_BROADCAST_SPACE_ID=7474971491470688296
|
||||||
|
COZE_BROADCAST_BOT_ID=7560643598174683145
|
||||||
|
|
||||||
|
# AI 服务配置(知识点分析 V2 - 测试阶段 Key)
|
||||||
|
AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw
|
||||||
|
AI_PRIMARY_BASE_URL=https://4sapi.com/v1
|
||||||
|
AI_FALLBACK_API_KEY=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
|
||||||
|
AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
AI_DEFAULT_MODEL=gemini-3-flash-preview
|
||||||
|
AI_TIMEOUT=120
|
||||||
|
|
||||||
|
# 租户配置(用于多租户部署)
|
||||||
|
TENANT_CODE=yy
|
||||||
|
|
||||||
|
# 管理库连接配置(用于从 tenant_configs 表读取配置)
|
||||||
|
ADMIN_DB_HOST=prod-mysql
|
||||||
|
ADMIN_DB_PORT=3306
|
||||||
|
ADMIN_DB_USER=root
|
||||||
|
ADMIN_DB_PASSWORD=ProdMySQL2025!@#
|
||||||
|
ADMIN_DB_NAME=kaopeilian_admin
|
||||||
79
backend/.gitignore
vendored
Normal file
79
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Virtual Environment
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.coverage
|
||||||
|
.pytest_cache/
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
local_config.py
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Alembic
|
||||||
|
alembic.ini
|
||||||
|
|
||||||
|
# Private keys
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.cert
|
||||||
25
backend/.pre-commit-config.yaml
Normal file
25
backend/.pre-commit-config.yaml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/psf/black
|
||||||
|
rev: 23.3.0
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
language_version: python3.8
|
||||||
|
|
||||||
|
- repo: https://github.com/pycqa/isort
|
||||||
|
rev: 5.12.0
|
||||||
|
hooks:
|
||||||
|
- id: isort
|
||||||
|
args: ["--profile", "black"]
|
||||||
|
|
||||||
|
- repo: https://github.com/pycqa/flake8
|
||||||
|
rev: 6.0.0
|
||||||
|
hooks:
|
||||||
|
- id: flake8
|
||||||
|
args: ["--max-line-length", "88", "--extend-ignore", "E203"]
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.3.0
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
additional_dependencies: [types-all]
|
||||||
|
exclude: ^(migrations/|tests/)
|
||||||
57
backend/Dockerfile
Normal file
57
backend/Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# 使用Python 3.11作为基础镜像,使用阿里云镜像
|
||||||
|
FROM python:3.11.9-slim
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
# 配置阿里云镜像源
|
||||||
|
RUN echo "deb http://mirrors.aliyun.com/debian/ bookworm main" > /etc/apt/sources.list && \
|
||||||
|
echo "deb http://mirrors.aliyun.com/debian-security/ bookworm-security main" >> /etc/apt/sources.list && \
|
||||||
|
echo "deb http://mirrors.aliyun.com/debian/ bookworm-updates main" >> /etc/apt/sources.list
|
||||||
|
|
||||||
|
# 安装系统依赖(包括LibreOffice用于文档转换)
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
default-libmysqlclient-dev \
|
||||||
|
pkg-config \
|
||||||
|
curl \
|
||||||
|
libreoffice-writer \
|
||||||
|
libreoffice-impress \
|
||||||
|
libreoffice-calc \
|
||||||
|
libreoffice-core \
|
||||||
|
fonts-wqy-zenhei \
|
||||||
|
fonts-wqy-microhei \
|
||||||
|
--no-install-recommends \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# 配置pip使用阿里云镜像
|
||||||
|
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \
|
||||||
|
pip config set global.trusted-host mirrors.aliyun.com
|
||||||
|
|
||||||
|
# 安装Python依赖
|
||||||
|
RUN pip install --upgrade pip && \
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 复制应用代码
|
||||||
|
COPY app/ ./app/
|
||||||
|
|
||||||
|
# 创建上传目录和日志目录
|
||||||
|
RUN mkdir -p uploads logs
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# 启动命令(生产模式,无热重载)
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--timeout-keep-alive", "600"]
|
||||||
66
backend/Dockerfile.admin
Normal file
66
backend/Dockerfile.admin
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# 考培练系统 SaaS 超级后台 - 开发环境 Dockerfile
|
||||||
|
# 使用阿里云镜像 + 热重载配置
|
||||||
|
|
||||||
|
# 使用 Python 3.11 slim 版本
|
||||||
|
FROM python:3.11.9-slim
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PYTHONPATH=/app
|
||||||
|
|
||||||
|
# 配置阿里云 apt 镜像源
|
||||||
|
RUN echo "deb http://mirrors.aliyun.com/debian/ bookworm main" > /etc/apt/sources.list && \
|
||||||
|
echo "deb http://mirrors.aliyun.com/debian-security/ bookworm-security main" >> /etc/apt/sources.list && \
|
||||||
|
echo "deb http://mirrors.aliyun.com/debian/ bookworm-updates main" >> /etc/apt/sources.list
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
default-libmysqlclient-dev \
|
||||||
|
pkg-config \
|
||||||
|
curl \
|
||||||
|
--no-install-recommends \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 配置 pip 使用阿里云镜像
|
||||||
|
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \
|
||||||
|
pip config set global.trusted-host mirrors.aliyun.com
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY requirements.txt .
|
||||||
|
COPY requirements-admin.txt .
|
||||||
|
|
||||||
|
# 安装 Python 依赖
|
||||||
|
RUN pip install --upgrade pip && \
|
||||||
|
pip install -r requirements.txt && \
|
||||||
|
pip install -r requirements-admin.txt
|
||||||
|
|
||||||
|
# 复制应用代码(开发环境会通过 volume 覆盖)
|
||||||
|
COPY app/ ./app/
|
||||||
|
|
||||||
|
# 创建目录
|
||||||
|
RUN mkdir -p uploads logs
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# 启动命令 - 开发模式,启用热重载
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--reload-dir", "/app/app"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
59
backend/Dockerfile.dev
Normal file
59
backend/Dockerfile.dev
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# 后端开发环境 Dockerfile(支持热重载)
|
||||||
|
FROM python:3.11.9-slim
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PYTHONPATH=/app
|
||||||
|
|
||||||
|
# 配置阿里云镜像源
|
||||||
|
RUN echo "deb http://mirrors.aliyun.com/debian/ bookworm main" > /etc/apt/sources.list && \
|
||||||
|
echo "deb http://mirrors.aliyun.com/debian-security/ bookworm-security main" >> /etc/apt/sources.list && \
|
||||||
|
echo "deb http://mirrors.aliyun.com/debian/ bookworm-updates main" >> /etc/apt/sources.list
|
||||||
|
|
||||||
|
# 安装系统依赖(包括LibreOffice用于文档转换)
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
default-libmysqlclient-dev \
|
||||||
|
pkg-config \
|
||||||
|
curl \
|
||||||
|
libreoffice-writer \
|
||||||
|
libreoffice-impress \
|
||||||
|
libreoffice-calc \
|
||||||
|
libreoffice-core \
|
||||||
|
fonts-wqy-zenhei \
|
||||||
|
fonts-wqy-microhei \
|
||||||
|
--no-install-recommends \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# 配置pip使用阿里云镜像
|
||||||
|
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \
|
||||||
|
pip config set global.trusted-host mirrors.aliyun.com
|
||||||
|
|
||||||
|
# 安装Python依赖
|
||||||
|
RUN pip install --upgrade pip && \
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 复制应用代码(开发时会被volume覆盖)
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 创建必要的目录
|
||||||
|
RUN mkdir -p uploads logs
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# 启动开发服务器(支持热重载)
|
||||||
|
# 设置超时为10分钟(600秒),以支持AI试题生成等长时间处理
|
||||||
|
CMD ["uvicorn", "app.main:app", "--reload", "--host", "0.0.0.0", "--port", "8000", "--reload-dir", "/app/app", "--timeout-keep-alive", "600"]
|
||||||
52
backend/Makefile
Normal file
52
backend/Makefile
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
.PHONY: help install install-dev format lint type-check test test-cov run migrate clean
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "可用的命令:"
|
||||||
|
@echo " make install - 安装生产环境依赖"
|
||||||
|
@echo " make install-dev - 安装开发环境依赖"
|
||||||
|
@echo " make format - 格式化代码"
|
||||||
|
@echo " make lint - 运行代码检查"
|
||||||
|
@echo " make type-check - 运行类型检查"
|
||||||
|
@echo " make test - 运行测试"
|
||||||
|
@echo " make test-cov - 运行测试并生成覆盖率报告"
|
||||||
|
@echo " make run - 启动开发服务器"
|
||||||
|
@echo " make migrate - 运行数据库迁移"
|
||||||
|
@echo " make clean - 清理临时文件"
|
||||||
|
|
||||||
|
install:
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
install-dev:
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
format:
|
||||||
|
black app/ tests/
|
||||||
|
isort app/ tests/
|
||||||
|
|
||||||
|
lint:
|
||||||
|
flake8 app/ tests/ --max-line-length=100 --ignore=E203,W503
|
||||||
|
pylint app/ tests/ --disable=C0111,R0903,R0913
|
||||||
|
|
||||||
|
type-check:
|
||||||
|
mypy app/ --ignore-missing-imports
|
||||||
|
|
||||||
|
test:
|
||||||
|
pytest tests/ -v
|
||||||
|
|
||||||
|
test-cov:
|
||||||
|
pytest tests/ -v --cov=app --cov-report=html --cov-report=term
|
||||||
|
|
||||||
|
run:
|
||||||
|
python -m app.main
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
clean:
|
||||||
|
find . -type d -name "__pycache__" -exec rm -rf {} +
|
||||||
|
find . -type f -name "*.pyc" -delete
|
||||||
|
find . -type f -name "*.pyo" -delete
|
||||||
|
find . -type f -name ".coverage" -delete
|
||||||
|
rm -rf htmlcov/
|
||||||
|
rm -rf .pytest_cache/
|
||||||
|
rm -rf .mypy_cache/
|
||||||
410
backend/README.md
Normal file
410
backend/README.md
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
## 考培练系统后端(FastAPI)
|
||||||
|
|
||||||
|
简要说明:本项目为考培练系统的后端服务,基于 FastAPI 开发,配套前端为 Vue3。支持本地开发与测试,默认仅在 localhost 环境运行。
|
||||||
|
|
||||||
|
### 如何运行(How to Run)
|
||||||
|
|
||||||
|
1. 进入项目目录:
|
||||||
|
```bash
|
||||||
|
cd kaopeilian-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 可选:创建并激活虚拟环境(推荐)
|
||||||
|
```bash
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 安装依赖:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 运行主程序 `main.py`:
|
||||||
|
```bash
|
||||||
|
python app/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
可选运行方式(等价):
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后访问接口文档:
|
||||||
|
```
|
||||||
|
http://localhost:8000/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
> 提示:如需设置数据库连接,请使用本地开发 DSN:`mysql+aiomysql://root:root@localhost:3306/kaopeilian?charset=utf8mb4`,可在 `local_config.py` 或环境变量 `DATABASE_URL` 中覆盖。
|
||||||
|
|
||||||
|
### 如何测试(How to Test)
|
||||||
|
|
||||||
|
1. 安装测试依赖(如未安装):
|
||||||
|
```bash
|
||||||
|
pip install pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 仅运行 `test_main.py`:
|
||||||
|
```bash
|
||||||
|
pytest tests/test_main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
(或运行全部测试)
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
# 考培练系统后端
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
考培练系统是一个革命性的员工能力提升平台,通过集成Coze和Dify双AI平台,实现智能化的培训、考核和陪练功能。
|
||||||
|
|
||||||
|
## 系统账户
|
||||||
|
|
||||||
|
系统预置了以下测试账户:
|
||||||
|
|
||||||
|
| 角色 | 用户名 | 密码 | 权限说明 |
|
||||||
|
| ---------- | ---------- | -------------- | ---------------------------- |
|
||||||
|
| 超级管理员 | superadmin | Superadmin123! | 系统最高权限,可管理所有功能 |
|
||||||
|
| 系统管理员 | admin | Admin123! | 可管理除“系统管理”模块外的全部功能(管理员仪表盘、用户管理、岗位管理、系统日志) |
|
||||||
|
| 测试学员 | testuser | TestPass123! | 可学习课程、参加考试和训练 |
|
||||||
|
|
||||||
|
**注意**:
|
||||||
|
|
||||||
|
- 这些账户支持两种密码加密方式(bcrypt 和 SHA256)
|
||||||
|
- 使用 `create_system_accounts.py` 创建 bcrypt 加密的账户(推荐用于生产环境)
|
||||||
|
- 使用 `create_simple_users.py` 创建 SHA256 加密的账户(用于 simple_main.py)
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **后端框架**: Python 3.8+ + FastAPI
|
||||||
|
- **数据库**: MySQL 8.0 + Redis
|
||||||
|
- **ORM**: SQLAlchemy 2.0
|
||||||
|
- **AI平台**: Coze(陪练和对话) + Dify(考试和评估)
|
||||||
|
- **认证**: JWT
|
||||||
|
- **文档转换**: LibreOffice(用于Office文档在线预览)
|
||||||
|
- **部署**: Docker
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
kaopeilian-backend/
|
||||||
|
├── app/ # 应用主目录
|
||||||
|
│ ├── api/ # API路由
|
||||||
|
│ │ └── v1/ # API v1版本
|
||||||
|
│ │ ├── training.py # 陪练模块API
|
||||||
|
│ │ └── ... # 其他模块API
|
||||||
|
│ ├── config/ # 配置管理
|
||||||
|
│ │ ├── settings.py # 系统配置
|
||||||
|
│ │ └── database.py # 数据库配置
|
||||||
|
│ ├── core/ # 核心功能
|
||||||
|
│ │ ├── deps.py # 依赖注入
|
||||||
|
│ │ ├── exceptions.py # 异常定义
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── models/ # 数据库模型
|
||||||
|
│ │ ├── base.py # 基础模型
|
||||||
|
│ │ ├── training.py # 陪练模型
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── schemas/ # Pydantic模式
|
||||||
|
│ │ ├── base.py # 基础模式
|
||||||
|
│ │ ├── training.py # 陪练模式
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── services/ # 业务逻辑
|
||||||
|
│ │ ├── base_service.py # 基础服务类
|
||||||
|
│ │ ├── training_service.py # 陪练服务
|
||||||
|
│ │ └── ai/ # AI平台集成
|
||||||
|
│ │ ├── coze/ # Coze集成
|
||||||
|
│ │ └── dify/ # Dify集成
|
||||||
|
│ └── main.py # 应用入口
|
||||||
|
├── tests/ # 测试目录
|
||||||
|
├── migrations/ # 数据库迁移
|
||||||
|
├── requirements.txt # 生产依赖
|
||||||
|
├── requirements-dev.txt # 开发依赖
|
||||||
|
├── Makefile # 开发命令
|
||||||
|
└── README.md # 项目说明
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 环境准备
|
||||||
|
|
||||||
|
- Python 3.8+
|
||||||
|
- MySQL 8.0
|
||||||
|
- Redis
|
||||||
|
|
||||||
|
### 2. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装生产依赖
|
||||||
|
make install
|
||||||
|
|
||||||
|
# 或安装开发依赖(包含测试和代码检查工具)
|
||||||
|
make install-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 配置环境变量
|
||||||
|
|
||||||
|
复制环境变量示例文件并修改配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
主要配置项:
|
||||||
|
|
||||||
|
- 数据库连接:`DATABASE_URL`
|
||||||
|
- Redis连接:`REDIS_URL`
|
||||||
|
- JWT密钥:`SECRET_KEY`
|
||||||
|
- Coze配置:`COZE_API_TOKEN`, `COZE_TRAINING_BOT_ID`
|
||||||
|
- Dify配置:`DIFY_API_KEY`
|
||||||
|
|
||||||
|
### 4. 数据库初始化
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行数据库迁移
|
||||||
|
make migrate
|
||||||
|
# 数据库结构说明更新
|
||||||
|
|
||||||
|
- 统一主键:根据当前 ORM 定义,所有表主键均为 `INT AUTO_INCREMENT`。
|
||||||
|
- 用户相关引用:`teams.leader_id`、`exams.user_id`、`user_teams.user_id` 统一为 `INT`。
|
||||||
|
- 陪练模块:
|
||||||
|
- `training_scenes.status` 使用枚举 `DRAFT/ACTIVE/INACTIVE`;
|
||||||
|
- `training_sessions.status` 使用枚举 `CREATED/IN_PROGRESS/COMPLETED/CANCELLED/ERROR`;
|
||||||
|
- `training_messages.role` 使用 `USER/ASSISTANT/SYSTEM`;`type` 使用 `TEXT/VOICE/SYSTEM`;
|
||||||
|
- `training_sessions.user_id` 和 `training_reports.user_id` 为 `INT`(取消 `training_sessions.user_id` 外键);
|
||||||
|
- `training_reports.session_id` 对 `training_sessions.id` 唯一外键保持不变。
|
||||||
|
|
||||||
|
如需全量初始化,请使用 `scripts/init_database_unified.sql`。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发模式(自动重载)
|
||||||
|
make run
|
||||||
|
|
||||||
|
# 或直接运行
|
||||||
|
python -m app.main
|
||||||
|
```
|
||||||
|
|
||||||
|
服务将在 http://localhost:8000 启动
|
||||||
|
|
||||||
|
## 数据库连接信息
|
||||||
|
|
||||||
|
- 公网数据库(当前使用)
|
||||||
|
- Host: `120.79.247.16` 或 `aiedu.ireborn.com.cn`
|
||||||
|
- Port: `3306`
|
||||||
|
- User: `root`
|
||||||
|
- Password: `Kaopeilian2025!@#`
|
||||||
|
- Database: `kaopeilian`
|
||||||
|
- DSN (Python SQLAlchemy): `mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4`
|
||||||
|
|
||||||
|
- 本地数据库(备用)
|
||||||
|
- Host: `127.0.0.1`
|
||||||
|
- Port: `3306`
|
||||||
|
- User: `root`
|
||||||
|
- Password: `root`
|
||||||
|
- Database: `kaopeilian`
|
||||||
|
- DSN (Python SQLAlchemy): `mysql+aiomysql://root:root@localhost:3306/kaopeilian?charset=utf8mb4`
|
||||||
|
|
||||||
|
- 配置写入位置
|
||||||
|
- 代码内用于本地开发覆盖:`local_config.py` 中的 `os.environ["DATABASE_URL"]`
|
||||||
|
- Docker 开发环境:`docker-compose.dev.yml` 中 `backend.environment.DATABASE_URL`
|
||||||
|
- 运行时环境变量文件:`.env`(如存在,将被容器挂载)
|
||||||
|
|
||||||
|
> 提示:开发测试环境仅用于本机 `localhost` 访问,已开启代码自动重载。
|
||||||
|
|
||||||
|
## 文件管理
|
||||||
|
|
||||||
|
### 文件存储结构
|
||||||
|
- **基础路径**: `/kaopeilian-backend/uploads/`
|
||||||
|
- **课程资料**: `uploads/courses/{course_id}/{filename}`
|
||||||
|
- **文件命名规则**: `{时间戳}_{8位哈希}.{扩展名}`
|
||||||
|
- 示例: `20250922213126_e21775bc.pdf`
|
||||||
|
|
||||||
|
### 文件上传
|
||||||
|
- **上传接口**:
|
||||||
|
- 通用上传: `POST /api/v1/upload/file`
|
||||||
|
- 课程资料上传: `POST /api/v1/upload/course/{course_id}/materials`
|
||||||
|
- **支持格式**: pdf、doc、docx、ppt、pptx、xls、xlsx、txt、md、zip、mp4、mp3、png、jpg、jpeg
|
||||||
|
- **大小限制**: 50MB
|
||||||
|
- **静态访问路径**: `http://localhost:8000/static/uploads/{相对路径}`
|
||||||
|
|
||||||
|
### 文件删除策略
|
||||||
|
1. **删除资料时**:
|
||||||
|
- 软删除数据库记录(标记 `is_deleted=true`)
|
||||||
|
- 同步删除物理文件
|
||||||
|
- 文件删除失败仅记录日志,不影响业务流程
|
||||||
|
|
||||||
|
2. **删除课程时**:
|
||||||
|
- 软删除课程记录
|
||||||
|
- 删除整个课程文件夹 (`uploads/courses/{course_id}/`)
|
||||||
|
- 使用 `shutil.rmtree` 递归删除
|
||||||
|
- 文件夹删除失败仅记录日志,不影响业务流程
|
||||||
|
|
||||||
|
### 相关配置
|
||||||
|
- **上传路径配置**: `app/core/config.py` 中的 `UPLOAD_PATH` 属性
|
||||||
|
- **静态文件服务**: `app/main.py` 中使用 `StaticFiles` 挂载
|
||||||
|
- **文件上传模块**: `app/api/v1/upload.py`
|
||||||
|
|
||||||
|
### 文档预览功能
|
||||||
|
|
||||||
|
#### 支持的文件格式
|
||||||
|
- **直接预览**: PDF、TXT、Markdown (md, mdx)、HTML、CSV、VTT、Properties
|
||||||
|
- **转换预览**: Word (doc, docx)、Excel (xls, xlsx) - 通过LibreOffice转换为PDF后预览
|
||||||
|
|
||||||
|
#### 系统依赖
|
||||||
|
- **LibreOffice**: 用于Office文档转换
|
||||||
|
- libreoffice-writer: Word文档支持
|
||||||
|
- libreoffice-calc: Excel文档支持
|
||||||
|
- libreoffice-impress: PowerPoint文档支持(未启用)
|
||||||
|
- libreoffice-core: 核心组件
|
||||||
|
- **中文字体**: 支持中文文档预览
|
||||||
|
- fonts-wqy-zenhei: 文泉驿正黑
|
||||||
|
- fonts-wqy-microhei: 文泉驿微米黑
|
||||||
|
|
||||||
|
#### 预览API
|
||||||
|
- 获取预览信息: `GET /api/v1/preview/material/{material_id}`
|
||||||
|
- 检查转换服务: `GET /api/v1/preview/check-converter`
|
||||||
|
|
||||||
|
#### 转换缓存机制
|
||||||
|
- 转换后的PDF存储在: `uploads/converted/{course_id}/{material_id}.pdf`
|
||||||
|
- 仅在源文件更新时重新转换
|
||||||
|
- 转换失败时自动降级为下载模式
|
||||||
|
|
||||||
|
## API文档
|
||||||
|
|
||||||
|
启动服务后,可以访问:
|
||||||
|
|
||||||
|
- Swagger UI: http://localhost:8000/docs
|
||||||
|
- ReDoc: http://localhost:8000/redoc
|
||||||
|
|
||||||
|
## 开发指南
|
||||||
|
|
||||||
|
### 代码规范
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 格式化代码
|
||||||
|
make format
|
||||||
|
|
||||||
|
# 运行代码检查
|
||||||
|
make lint
|
||||||
|
|
||||||
|
# 运行类型检查
|
||||||
|
make type-check
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行测试
|
||||||
|
make test
|
||||||
|
|
||||||
|
# 运行测试并生成覆盖率报告
|
||||||
|
make test-cov
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模块开发流程
|
||||||
|
|
||||||
|
1. 在 `app/models/` 创建数据模型
|
||||||
|
2. 在 `app/schemas/` 创建Pydantic模式
|
||||||
|
3. 在 `app/services/` 实现业务逻辑
|
||||||
|
4. 在 `app/api/v1/` 创建API路由
|
||||||
|
5. 编写测试用例
|
||||||
|
6. 更新API契约文档
|
||||||
|
|
||||||
|
## 已实现功能
|
||||||
|
|
||||||
|
### 陪练模块 (Training)
|
||||||
|
|
||||||
|
- **场景管理**
|
||||||
|
|
||||||
|
- 获取场景列表(支持分类、状态筛选)
|
||||||
|
- 创建/更新/删除场景(管理员权限)
|
||||||
|
- 获取场景详情
|
||||||
|
- **会话管理**
|
||||||
|
|
||||||
|
- 开始陪练会话
|
||||||
|
- 结束陪练会话
|
||||||
|
- 获取会话列表
|
||||||
|
- 获取会话详情
|
||||||
|
- **消息管理**
|
||||||
|
|
||||||
|
- 获取会话消息列表
|
||||||
|
- 支持文本/语音消息
|
||||||
|
- **报告管理**
|
||||||
|
|
||||||
|
- 生成陪练报告
|
||||||
|
- 获取报告列表
|
||||||
|
- 获取报告详情
|
||||||
|
|
||||||
|
## 待实现功能
|
||||||
|
|
||||||
|
- [ ] 用户认证模块 (Auth)
|
||||||
|
- [ ] 用户管理模块 (User)
|
||||||
|
- [ ] 课程管理模块 (Course)
|
||||||
|
- [ ] 考试模块 (Exam)
|
||||||
|
- [ ] 数据分析模块 (Analytics)
|
||||||
|
- [ ] 系统管理模块 (Admin)
|
||||||
|
- [ ] Coze网关模块
|
||||||
|
- [ ] WebSocket实时通信
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
## 常见问题与排错
|
||||||
|
|
||||||
|
### 1. 登录失败相关
|
||||||
|
|
||||||
|
- 报错 Unknown column 'users.is_deleted':请更新数据库,确保 `users` 表包含 `is_deleted` 与 `deleted_at` 字段(参见 `scripts/init_database_unified.sql` 或执行迁移)。
|
||||||
|
- 默认账户无法登录:重置默认账户密码哈希或运行 `create_system_accounts.py`。默认账户见上方“系统账户”。
|
||||||
|
|
||||||
|
### 2. 依赖冲突(httpx 与 cozepy)
|
||||||
|
|
||||||
|
- `cozepy==0.2.0` 依赖 `httpx<0.25.0`,请将 `requirements.txt` 中 `httpx` 固定为 `0.24.1`,并避免在 `requirements-dev.txt` 再次指定其他版本。
|
||||||
|
|
||||||
|
### 3. Docker 拉取镜像超时
|
||||||
|
|
||||||
|
- 可先本地直接运行后端(确保本机 MySQL/Redis 就绪),调通后再处理 Docker 网络问题。
|
||||||
|
|
||||||
|
|
||||||
|
### Docker部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建镜像
|
||||||
|
docker build -t kaopeilian-backend .
|
||||||
|
|
||||||
|
# 运行容器
|
||||||
|
docker run -d -p 8000:8000 --env-file .env kaopeilian-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动所有服务
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 贡献指南
|
||||||
|
|
||||||
|
1. Fork项目
|
||||||
|
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||||
|
3. 提交代码 (`git commit -m 'feat: Add some AmazingFeature'`)
|
||||||
|
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||||
|
5. 创建Pull Request
|
||||||
|
|
||||||
|
### 提交规范
|
||||||
|
|
||||||
|
- `feat`: 新功能
|
||||||
|
- `fix`: 修复bug
|
||||||
|
- `docs`: 文档更新
|
||||||
|
- `style`: 代码格式调整
|
||||||
|
- `refactor`: 代码重构
|
||||||
|
- `test`: 测试相关
|
||||||
|
- `chore`: 构建过程或辅助工具的变动
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
本项目采用 MIT 许可证
|
||||||
142
backend/SQL_EXECUTOR_FINAL_SUMMARY.md
Normal file
142
backend/SQL_EXECUTOR_FINAL_SUMMARY.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# 🎯 SQL 执行器 API 开发完成总结
|
||||||
|
|
||||||
|
## ✅ 项目状态:开发完成,本地测试通过
|
||||||
|
|
||||||
|
## 📦 交付内容
|
||||||
|
|
||||||
|
### 1. API 端点
|
||||||
|
- ✅ `/api/v1/sql/execute` - 标准JWT认证版
|
||||||
|
- ✅ `/api/v1/sql/execute-simple` - 简化认证版(推荐Dify使用)
|
||||||
|
- ✅ `/api/v1/sql/validate` - SQL语法验证
|
||||||
|
- ✅ `/api/v1/sql/tables` - 获取表列表
|
||||||
|
- ✅ `/api/v1/sql/table/{name}/schema` - 获取表结构
|
||||||
|
|
||||||
|
### 2. 认证方式
|
||||||
|
- ✅ **API Key**(推荐): `X-API-Key: dify-2025-kaopeilian`
|
||||||
|
- ✅ **长期Token**: `Authorization: Bearer permanent-token-for-dify-2025`
|
||||||
|
- ✅ **标准JWT**: 通过登录接口获取(30分钟有效期)
|
||||||
|
|
||||||
|
### 3. 文档
|
||||||
|
- ✅ `docs/openapi_sql_executor.yaml` - OpenAPI 3.1规范(YAML)
|
||||||
|
- ✅ `docs/openapi_sql_executor.json` - OpenAPI 3.1规范(JSON)
|
||||||
|
- ✅ `docs/dify_integration_summary.md` - Dify集成指南
|
||||||
|
- ✅ `deploy/server_setup_guide.md` - 服务器部署指南
|
||||||
|
- ✅ `deploy/quick_deploy.sh` - 一键部署脚本
|
||||||
|
|
||||||
|
### 4. 核心代码
|
||||||
|
- ✅ `app/api/v1/sql_executor.py` - 主要API实现
|
||||||
|
- ✅ `app/core/simple_auth.py` - 简化认证实现
|
||||||
|
- ✅ `test_sql_executor.py` - 测试脚本
|
||||||
|
|
||||||
|
## 🚀 Dify 快速配置
|
||||||
|
|
||||||
|
### 方式一:导入OpenAPI(推荐)
|
||||||
|
1. 导入 `openapi_sql_executor.yaml`
|
||||||
|
2. 选择服务器:120.79.247.16:8000
|
||||||
|
3. 配置认证(见下方)
|
||||||
|
|
||||||
|
### 方式二:手动配置
|
||||||
|
```
|
||||||
|
URL: http://120.79.247.16:8000/api/v1/sql/execute-simple
|
||||||
|
方法: POST
|
||||||
|
鉴权类型: 请求头
|
||||||
|
鉴权头部前缀: Custom
|
||||||
|
键: X-API-Key
|
||||||
|
值: dify-2025-kaopeilian
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💡 使用示例
|
||||||
|
|
||||||
|
### 简单查询
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sql": "SELECT * FROM users LIMIT 5"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数化查询
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sql": "SELECT * FROM courses WHERE category = :category",
|
||||||
|
"params": {"category": "护肤"}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据插入
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sql": "INSERT INTO knowledge_points (title, content, course_id) VALUES (:title, :content, :course_id)",
|
||||||
|
"params": {
|
||||||
|
"title": "面部护理",
|
||||||
|
"content": "详细内容",
|
||||||
|
"course_id": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌐 服务器部署步骤
|
||||||
|
|
||||||
|
1. **上传代码到服务器**
|
||||||
|
```bash
|
||||||
|
scp -r * root@120.79.247.16:/opt/kaopeilian/backend/
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **运行部署脚本**
|
||||||
|
```bash
|
||||||
|
ssh root@120.79.247.16
|
||||||
|
cd /opt/kaopeilian/backend
|
||||||
|
bash deploy/quick_deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **验证部署**
|
||||||
|
```bash
|
||||||
|
curl http://120.79.247.16:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 测试结果
|
||||||
|
|
||||||
|
### 本地测试(全部通过 ✅)
|
||||||
|
- 健康检查:✅ 正常
|
||||||
|
- API Key认证:✅ 成功
|
||||||
|
- 长期Token认证:✅ 成功
|
||||||
|
- 参数化查询:✅ 成功
|
||||||
|
- 数据写入:✅ 成功
|
||||||
|
|
||||||
|
### 公网测试(待部署)
|
||||||
|
- 服务尚未部署到公网服务器
|
||||||
|
- 需要执行部署脚本
|
||||||
|
|
||||||
|
## 🔐 安全建议
|
||||||
|
|
||||||
|
1. **生产环境**
|
||||||
|
- 修改默认API Key和Token
|
||||||
|
- 使用环境变量管理密钥
|
||||||
|
- 启用HTTPS加密传输
|
||||||
|
|
||||||
|
2. **访问控制**
|
||||||
|
- 配置防火墙限制IP
|
||||||
|
- 定期更换认证密钥
|
||||||
|
- 监控异常访问
|
||||||
|
|
||||||
|
## 📞 技术支持
|
||||||
|
|
||||||
|
- 本地测试端口:8000
|
||||||
|
- 服务器地址:120.79.247.16
|
||||||
|
- 数据库:kaopeilian
|
||||||
|
- 认证密钥:已在文档中提供
|
||||||
|
|
||||||
|
## ⏰ 时间线
|
||||||
|
|
||||||
|
- 开发开始:2025-09-23 14:00
|
||||||
|
- 开发完成:2025-09-23 16:30
|
||||||
|
- 本地测试:✅ 通过
|
||||||
|
- 生产部署:⏳ 待执行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**当前状态**:开发完成,本地测试通过,等待部署到生产环境。
|
||||||
|
|
||||||
|
**下一步**:
|
||||||
|
1. 执行服务器部署
|
||||||
|
2. 在Dify中配置使用
|
||||||
|
3. 集成到实际工作流
|
||||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
10
backend/alembic/versions/add_course_fields.sql
Normal file
10
backend/alembic/versions/add_course_fields.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- 为课程表添加学员统计等字段
|
||||||
|
|
||||||
|
ALTER TABLE `courses`
|
||||||
|
ADD COLUMN `student_count` INT DEFAULT 0 COMMENT '学习人数' AFTER `is_featured`,
|
||||||
|
ADD COLUMN `is_new` BOOLEAN DEFAULT TRUE COMMENT '是否新课程(最近30天内发布)' AFTER `student_count`,
|
||||||
|
ADD INDEX `idx_student_count` (`student_count`),
|
||||||
|
ADD INDEX `idx_is_new` (`is_new`);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
12
backend/alembic/versions/add_mistake_mastery_fields.sql
Normal file
12
backend/alembic/versions/add_mistake_mastery_fields.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- 为错题表添加掌握状态和统计字段
|
||||||
|
|
||||||
|
ALTER TABLE `exam_mistakes`
|
||||||
|
ADD COLUMN `mastery_status` VARCHAR(20) DEFAULT 'unmastered' COMMENT '掌握状态: unmastered-未掌握, mastered-已掌握' AFTER `question_type`,
|
||||||
|
ADD COLUMN `difficulty` VARCHAR(20) DEFAULT 'medium' COMMENT '题目难度: easy-简单, medium-中等, hard-困难' AFTER `mastery_status`,
|
||||||
|
ADD COLUMN `wrong_count` INT DEFAULT 1 COMMENT '错误次数统计' AFTER `difficulty`,
|
||||||
|
ADD COLUMN `mastered_at` DATETIME NULL COMMENT '标记掌握时间' AFTER `wrong_count`,
|
||||||
|
ADD INDEX `idx_mastery_status` (`mastery_status`),
|
||||||
|
ADD INDEX `idx_difficulty` (`difficulty`);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
30
backend/alembic/versions/create_system_logs_table.sql
Normal file
30
backend/alembic/versions/create_system_logs_table.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- 创建系统日志表
|
||||||
|
-- 用于记录系统操作、错误、安全事件等日志信息
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `system_logs` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '日志ID',
|
||||||
|
`level` VARCHAR(20) NOT NULL COMMENT '日志级别: debug, info, warning, error',
|
||||||
|
`type` VARCHAR(50) NOT NULL COMMENT '日志类型: system, user, api, error, security',
|
||||||
|
`user` VARCHAR(100) NULL COMMENT '操作用户',
|
||||||
|
`user_id` INT NULL COMMENT '用户ID',
|
||||||
|
`ip` VARCHAR(100) NULL COMMENT 'IP地址',
|
||||||
|
`message` TEXT NOT NULL COMMENT '日志消息',
|
||||||
|
`user_agent` VARCHAR(500) NULL COMMENT 'User Agent',
|
||||||
|
`path` VARCHAR(500) NULL COMMENT '请求路径(API路径)',
|
||||||
|
`method` VARCHAR(10) NULL COMMENT '请求方法',
|
||||||
|
`extra_data` TEXT NULL COMMENT '额外数据(JSON格式)',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_system_logs_level` (`level`),
|
||||||
|
INDEX `idx_system_logs_type` (`type`),
|
||||||
|
INDEX `idx_system_logs_user` (`user`),
|
||||||
|
INDEX `idx_system_logs_user_id` (`user_id`),
|
||||||
|
INDEX `idx_system_logs_path` (`path`),
|
||||||
|
INDEX `idx_system_logs_created_at` (`created_at`),
|
||||||
|
INDEX `idx_system_logs_level_type` (`level`, `type`),
|
||||||
|
INDEX `idx_system_logs_user_created` (`user`, `created_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统日志表';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
50
backend/alembic/versions/create_tasks_table.sql
Normal file
50
backend/alembic/versions/create_tasks_table.sql
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
-- 创建任务表
|
||||||
|
CREATE TABLE `tasks` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`title` VARCHAR(200) NOT NULL COMMENT '任务标题',
|
||||||
|
`description` TEXT COMMENT '任务描述',
|
||||||
|
`priority` ENUM('low', 'medium', 'high') DEFAULT 'medium' COMMENT '优先级',
|
||||||
|
`status` ENUM('pending', 'ongoing', 'completed', 'expired') DEFAULT 'pending' COMMENT '任务状态',
|
||||||
|
`creator_id` INT NOT NULL COMMENT '创建人ID',
|
||||||
|
`deadline` DATETIME COMMENT '截止时间',
|
||||||
|
`requirements` JSON COMMENT '任务要求配置',
|
||||||
|
`progress` INT DEFAULT 0 COMMENT '完成进度(0-100)',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
`is_deleted` BOOLEAN DEFAULT FALSE,
|
||||||
|
INDEX `idx_status` (`status`),
|
||||||
|
INDEX `idx_creator` (`creator_id`),
|
||||||
|
INDEX `idx_deadline` (`deadline`),
|
||||||
|
FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务表';
|
||||||
|
|
||||||
|
-- 创建任务课程关联表
|
||||||
|
CREATE TABLE `task_courses` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`task_id` INT NOT NULL COMMENT '任务ID',
|
||||||
|
`course_id` INT NOT NULL COMMENT '课程ID',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY `uk_task_course` (`task_id`, `course_id`),
|
||||||
|
FOREIGN KEY (`task_id`) REFERENCES `tasks`(`id`) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务课程关联表';
|
||||||
|
|
||||||
|
-- 创建任务分配表
|
||||||
|
CREATE TABLE `task_assignments` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`task_id` INT NOT NULL COMMENT '任务ID',
|
||||||
|
`user_id` INT NOT NULL COMMENT '分配用户ID',
|
||||||
|
`team_id` INT DEFAULT NULL COMMENT '团队ID(如果按团队分配)',
|
||||||
|
`status` ENUM('not_started', 'in_progress', 'completed') DEFAULT 'not_started' COMMENT '完成状态',
|
||||||
|
`progress` INT DEFAULT 0 COMMENT '个人完成进度(0-100)',
|
||||||
|
`completed_at` DATETIME COMMENT '完成时间',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY `uk_task_user` (`task_id`, `user_id`),
|
||||||
|
INDEX `idx_status` (`status`),
|
||||||
|
FOREIGN KEY (`task_id`) REFERENCES `tasks`(`id`) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务分配表';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""考培练系统后端应用包"""
|
||||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# API 路由模块
|
||||||
497
backend/app/api/v1/03-Agent-Course/api_contract.yaml
Normal file
497
backend/app/api/v1/03-Agent-Course/api_contract.yaml
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
openapi: 3.0.0
|
||||||
|
info:
|
||||||
|
title: 课程管理模块API契约
|
||||||
|
description: 定义课程管理模块对外提供的所有API接口
|
||||||
|
version: 1.0.0
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8000/api/v1
|
||||||
|
description: 本地开发服务器
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/courses:
|
||||||
|
get:
|
||||||
|
summary: 获取课程列表
|
||||||
|
description: 支持分页和多条件筛选
|
||||||
|
operationId: getCourses
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
default: 1
|
||||||
|
- name: size
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
default: 20
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [draft, published, archived]
|
||||||
|
- name: category
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [technology, management, business, general]
|
||||||
|
- name: is_featured
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
- name: keyword
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 成功获取课程列表
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CoursePageResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/UnauthorizedError"
|
||||||
|
|
||||||
|
post:
|
||||||
|
summary: 创建课程
|
||||||
|
description: 创建新课程(需要管理员权限)
|
||||||
|
operationId: createCourse
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CourseCreate"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: 成功创建课程
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CourseResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/UnauthorizedError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/ForbiddenError"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/ConflictError"
|
||||||
|
|
||||||
|
/courses/{courseId}:
|
||||||
|
get:
|
||||||
|
summary: 获取课程详情
|
||||||
|
operationId: getCourse
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: courseId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 成功获取课程详情
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CourseResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/UnauthorizedError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFoundError"
|
||||||
|
|
||||||
|
put:
|
||||||
|
summary: 更新课程
|
||||||
|
description: 更新课程信息(需要管理员权限)
|
||||||
|
operationId: updateCourse
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: courseId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CourseUpdate"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 成功更新课程
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CourseResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/UnauthorizedError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/ForbiddenError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFoundError"
|
||||||
|
|
||||||
|
delete:
|
||||||
|
summary: 删除课程
|
||||||
|
description: 软删除课程(需要管理员权限)
|
||||||
|
operationId: deleteCourse
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: courseId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 成功删除课程
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/DeleteResponse"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequestError"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/UnauthorizedError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/ForbiddenError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFoundError"
|
||||||
|
|
||||||
|
/courses/{courseId}/knowledge-points:
|
||||||
|
get:
|
||||||
|
summary: 获取课程知识点列表
|
||||||
|
operationId: getCourseKnowledgePoints
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: courseId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: parent_id
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 成功获取知识点列表
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/KnowledgePointListResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/UnauthorizedError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFoundError"
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
ResponseBase:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- code
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: integer
|
||||||
|
default: 200
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
request_id:
|
||||||
|
type: string
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
CourseBase:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 200
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
category:
|
||||||
|
type: string
|
||||||
|
enum: [technology, management, business, general]
|
||||||
|
default: general
|
||||||
|
cover_image:
|
||||||
|
type: string
|
||||||
|
maxLength: 500
|
||||||
|
duration_hours:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
minimum: 0
|
||||||
|
difficulty_level:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 5
|
||||||
|
tags:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
sort_order:
|
||||||
|
type: integer
|
||||||
|
default: 0
|
||||||
|
is_featured:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
CourseCreate:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/CourseBase"
|
||||||
|
- type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [draft, published, archived]
|
||||||
|
default: draft
|
||||||
|
|
||||||
|
CourseUpdate:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/CourseBase"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [draft, published, archived]
|
||||||
|
|
||||||
|
Course:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/CourseBase"
|
||||||
|
- type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- status
|
||||||
|
- created_at
|
||||||
|
- updated_at
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [draft, published, archived]
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
published_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
publisher_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
created_by:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
updated_by:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
|
||||||
|
CourseResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/ResponseBase"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: "#/components/schemas/Course"
|
||||||
|
|
||||||
|
CoursePageResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/ResponseBase"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- items
|
||||||
|
- total
|
||||||
|
- page
|
||||||
|
- size
|
||||||
|
- pages
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Course"
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
page:
|
||||||
|
type: integer
|
||||||
|
size:
|
||||||
|
type: integer
|
||||||
|
pages:
|
||||||
|
type: integer
|
||||||
|
|
||||||
|
KnowledgePoint:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- course_id
|
||||||
|
- name
|
||||||
|
- level
|
||||||
|
- created_at
|
||||||
|
- updated_at
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
course_id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
maxLength: 200
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
parent_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
level:
|
||||||
|
type: integer
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
sort_order:
|
||||||
|
type: integer
|
||||||
|
weight:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
is_required:
|
||||||
|
type: boolean
|
||||||
|
estimated_hours:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
nullable: true
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
KnowledgePointListResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/ResponseBase"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/KnowledgePoint"
|
||||||
|
|
||||||
|
DeleteResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/ResponseBase"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
ErrorDetail:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
error_code:
|
||||||
|
type: string
|
||||||
|
field:
|
||||||
|
type: string
|
||||||
|
details:
|
||||||
|
type: object
|
||||||
|
|
||||||
|
responses:
|
||||||
|
BadRequestError:
|
||||||
|
description: 请求参数错误
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/ResponseBase"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
example: 400
|
||||||
|
detail:
|
||||||
|
$ref: "#/components/schemas/ErrorDetail"
|
||||||
|
|
||||||
|
UnauthorizedError:
|
||||||
|
description: 未认证
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/ResponseBase"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
example: 401
|
||||||
|
detail:
|
||||||
|
$ref: "#/components/schemas/ErrorDetail"
|
||||||
|
|
||||||
|
ForbiddenError:
|
||||||
|
description: 权限不足
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/ResponseBase"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
example: 403
|
||||||
|
detail:
|
||||||
|
$ref: "#/components/schemas/ErrorDetail"
|
||||||
|
|
||||||
|
NotFoundError:
|
||||||
|
description: 资源不存在
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/ResponseBase"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
example: 404
|
||||||
|
detail:
|
||||||
|
$ref: "#/components/schemas/ErrorDetail"
|
||||||
|
|
||||||
|
ConflictError:
|
||||||
|
description: 资源冲突
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/ResponseBase"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
example: 409
|
||||||
|
detail:
|
||||||
|
$ref: "#/components/schemas/ErrorDetail"
|
||||||
105
backend/app/api/v1/__init__.py
Normal file
105
backend/app/api/v1/__init__.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""
|
||||||
|
API v1 版本模块
|
||||||
|
整合所有 v1 版本的路由
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
# 先只导入必要的路由
|
||||||
|
from .coze_gateway import router as coze_router
|
||||||
|
|
||||||
|
# 创建 v1 版本的主路由
|
||||||
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
# 包含各个子路由
|
||||||
|
api_router.include_router(coze_router, tags=["coze"])
|
||||||
|
|
||||||
|
# TODO: 逐步添加其他路由
|
||||||
|
from .auth import router as auth_router
|
||||||
|
from .courses import router as courses_router
|
||||||
|
from .users import router as users_router
|
||||||
|
from .training import router as training_router
|
||||||
|
from .admin import router as admin_router
|
||||||
|
from .positions import router as positions_router
|
||||||
|
from .upload import router as upload_router
|
||||||
|
from .teams import router as teams_router
|
||||||
|
from .knowledge_analysis import router as knowledge_analysis_router
|
||||||
|
from .system import router as system_router
|
||||||
|
from .sql_executor import router as sql_executor_router
|
||||||
|
|
||||||
|
from .exam import router as exam_router
|
||||||
|
from .practice import router as practice_router
|
||||||
|
from .course_chat import router as course_chat_router
|
||||||
|
from .broadcast import router as broadcast_router
|
||||||
|
from .preview import router as preview_router
|
||||||
|
from .yanji import router as yanji_router
|
||||||
|
from .ability import router as ability_router
|
||||||
|
from .statistics import router as statistics_router
|
||||||
|
from .team_dashboard import router as team_dashboard_router
|
||||||
|
from .team_management import router as team_management_router
|
||||||
|
# Manager 模块路由
|
||||||
|
from .manager import student_scores_router, student_practice_router
|
||||||
|
from .system_logs import router as system_logs_router
|
||||||
|
from .tasks import router as tasks_router
|
||||||
|
from .endpoints.employee_sync import router as employee_sync_router
|
||||||
|
from .notifications import router as notifications_router
|
||||||
|
from .scrm import router as scrm_router
|
||||||
|
# 管理后台路由
|
||||||
|
from .admin_portal import router as admin_portal_router
|
||||||
|
|
||||||
|
api_router.include_router(auth_router, prefix="/auth", tags=["auth"])
|
||||||
|
# courses_router 已在内部定义了 prefix="/courses",此处不再额外添加前缀
|
||||||
|
api_router.include_router(courses_router, tags=["courses"])
|
||||||
|
api_router.include_router(users_router, prefix="/users", tags=["users"])
|
||||||
|
# training_router 已在内部定义了 prefix="/training",此处不再额外添加前缀
|
||||||
|
api_router.include_router(training_router, tags=["training"])
|
||||||
|
# admin_router 已在内部定义了 prefix="/admin",此处不再额外添加前缀
|
||||||
|
api_router.include_router(admin_router, tags=["admin"])
|
||||||
|
api_router.include_router(positions_router, tags=["positions"])
|
||||||
|
# upload_router 已在内部定义了 prefix="/upload",此处不再额外添加前缀
|
||||||
|
api_router.include_router(upload_router, tags=["upload"])
|
||||||
|
api_router.include_router(teams_router, tags=["teams"])
|
||||||
|
# knowledge_analysis_router 不需要额外前缀,路径已在路由中定义
|
||||||
|
api_router.include_router(knowledge_analysis_router, tags=["knowledge-analysis"])
|
||||||
|
# system_router 已在内部定义了 prefix="/system",此处不再额外添加前缀
|
||||||
|
api_router.include_router(system_router, tags=["system"])
|
||||||
|
# sql_executor_router SQL 执行器
|
||||||
|
api_router.include_router(sql_executor_router, prefix="/sql", tags=["sql-executor"])
|
||||||
|
# exam_router 已在内部定义了 prefix="/exams",此处不再额外添加前缀
|
||||||
|
api_router.include_router(exam_router, tags=["exams"])
|
||||||
|
# practice_router 陪练功能路由
|
||||||
|
api_router.include_router(practice_router, prefix="/practice", tags=["practice"])
|
||||||
|
# course_chat_router 与课程对话路由
|
||||||
|
api_router.include_router(course_chat_router, prefix="/course", tags=["course-chat"])
|
||||||
|
# broadcast_router 播课功能路由(不添加prefix,路径在router内部定义)
|
||||||
|
api_router.include_router(broadcast_router, tags=["broadcast"])
|
||||||
|
# preview_router 文件预览路由
|
||||||
|
api_router.include_router(preview_router, prefix="/preview", tags=["preview"])
|
||||||
|
# yanji_router 言迹智能工牌路由
|
||||||
|
api_router.include_router(yanji_router, prefix="/yanji", tags=["yanji"])
|
||||||
|
# ability_router 能力评估路由
|
||||||
|
api_router.include_router(ability_router, prefix="/ability", tags=["ability"])
|
||||||
|
# statistics_router 统计分析路由(不添加prefix,路径在router内部定义)
|
||||||
|
api_router.include_router(statistics_router, tags=["statistics"])
|
||||||
|
# team_dashboard_router 团队看板路由(不添加prefix,路径在router内部定义为/team/dashboard)
|
||||||
|
api_router.include_router(team_dashboard_router, tags=["team-dashboard"])
|
||||||
|
# team_management_router 团队成员管理路由(不添加prefix,路径在router内部定义为/team/management)
|
||||||
|
api_router.include_router(team_management_router, tags=["team-management"])
|
||||||
|
# student_scores_router 学员考试成绩管理路由(不添加prefix,路径在router内部定义为/manager/student-scores)
|
||||||
|
api_router.include_router(student_scores_router, tags=["manager-student-scores"])
|
||||||
|
# student_practice_router 学员陪练记录管理路由(不添加prefix,路径在router内部定义为/manager/student-practice)
|
||||||
|
api_router.include_router(student_practice_router, tags=["manager-student-practice"])
|
||||||
|
# system_logs_router 系统日志路由(不添加prefix,路径在router内部定义为/admin/logs)
|
||||||
|
api_router.include_router(system_logs_router, tags=["system-logs"])
|
||||||
|
# tasks_router 任务管理路由(不添加prefix,路径在router内部定义为/manager/tasks)
|
||||||
|
api_router.include_router(tasks_router, tags=["tasks"])
|
||||||
|
# employee_sync_router 员工同步路由
|
||||||
|
api_router.include_router(employee_sync_router, prefix="/employee-sync", tags=["employee-sync"])
|
||||||
|
# notifications_router 站内消息通知路由(不添加prefix,路径在router内部定义为/notifications)
|
||||||
|
api_router.include_router(notifications_router, tags=["notifications"])
|
||||||
|
# scrm_router SCRM系统对接路由(prefix在router内部定义为/scrm)
|
||||||
|
api_router.include_router(scrm_router, tags=["scrm"])
|
||||||
|
# admin_portal_router SaaS超级管理后台路由(prefix在router内部定义为/admin)
|
||||||
|
api_router.include_router(admin_portal_router, tags=["admin-portal"])
|
||||||
|
|
||||||
|
__all__ = ["api_router"]
|
||||||
187
backend/app/api/v1/ability.py
Normal file
187
backend/app/api/v1/ability.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"""
|
||||||
|
能力评估API接口
|
||||||
|
用于智能工牌数据分析、能力评估报告生成等
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
from app.schemas.ability import AbilityAssessmentResponse, AbilityAssessmentHistory
|
||||||
|
from app.services.yanji_service import YanjiService
|
||||||
|
from app.services.ability_assessment_service import get_ability_assessment_service
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/analyze-yanji", response_model=ResponseModel)
|
||||||
|
async def analyze_yanji_badge_data(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
分析智能工牌数据生成能力评估和课程推荐
|
||||||
|
|
||||||
|
使用 Python 原生 AI 服务实现。
|
||||||
|
|
||||||
|
功能说明:
|
||||||
|
1. 从言迹智能工牌获取员工的最近10条录音记录
|
||||||
|
2. 分析对话数据,进行能力评估(6个维度)
|
||||||
|
3. 基于能力短板生成课程推荐(3-5门)
|
||||||
|
4. 保存评估记录到数据库
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- 用户必须已绑定手机号(用于匹配言迹数据)
|
||||||
|
|
||||||
|
返回:
|
||||||
|
- assessment_id: 评估记录ID
|
||||||
|
- total_score: 综合评分(0-100)
|
||||||
|
- dimensions: 能力维度列表(6个维度)
|
||||||
|
- recommended_courses: 推荐课程列表(3-5门)
|
||||||
|
- conversation_count: 分析的对话数量
|
||||||
|
"""
|
||||||
|
# 检查用户是否绑定手机号
|
||||||
|
if not current_user.phone:
|
||||||
|
logger.warning(f"用户未绑定手机号: user_id={current_user.id}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="用户未绑定手机号,无法匹配言迹数据"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取服务实例
|
||||||
|
yanji_service = YanjiService()
|
||||||
|
assessment_service = get_ability_assessment_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
f"开始分析智能工牌数据: user_id={current_user.id}, "
|
||||||
|
f"phone={current_user.phone}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 调用能力评估服务(使用 Python 原生实现)
|
||||||
|
result = await assessment_service.analyze_yanji_conversations(
|
||||||
|
user_id=current_user.id,
|
||||||
|
phone=current_user.phone,
|
||||||
|
db=db,
|
||||||
|
yanji_service=yanji_service,
|
||||||
|
engine="v2" # 固定使用 V2
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"智能工牌数据分析完成: user_id={current_user.id}, "
|
||||||
|
f"assessment_id={result['assessment_id']}, "
|
||||||
|
f"total_score={result['total_score']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="智能工牌数据分析完成",
|
||||||
|
data=result
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
# 业务逻辑错误(如未找到录音记录)
|
||||||
|
logger.warning(f"智能工牌数据分析失败: {e}")
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# 系统错误
|
||||||
|
logger.error(f"分析智能工牌数据失败: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"分析失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history", response_model=ResponseModel)
|
||||||
|
async def get_assessment_history(
|
||||||
|
limit: int = Query(default=10, ge=1, le=50, description="返回记录数量"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取用户的能力评估历史记录
|
||||||
|
|
||||||
|
参数:
|
||||||
|
- limit: 返回记录数量(默认10,最大50)
|
||||||
|
|
||||||
|
返回:
|
||||||
|
- 评估历史记录列表
|
||||||
|
"""
|
||||||
|
assessment_service = get_ability_assessment_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
history = await assessment_service.get_user_assessment_history(
|
||||||
|
user_id=current_user.id,
|
||||||
|
db=db,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message=f"获取评估历史成功,共{len(history)}条",
|
||||||
|
data={"history": history, "total": len(history)}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取评估历史失败: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"获取评估历史失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{assessment_id}", response_model=ResponseModel)
|
||||||
|
async def get_assessment_detail(
|
||||||
|
assessment_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取单个评估记录的详细信息
|
||||||
|
|
||||||
|
参数:
|
||||||
|
- assessment_id: 评估记录ID
|
||||||
|
|
||||||
|
返回:
|
||||||
|
- 评估详细信息
|
||||||
|
"""
|
||||||
|
assessment_service = get_ability_assessment_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
detail = await assessment_service.get_assessment_detail(
|
||||||
|
assessment_id=assessment_id,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
# 权限检查:只能查看自己的评估记录
|
||||||
|
if detail['user_id'] != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="无权访问该评估记录"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取评估详情成功",
|
||||||
|
data=detail
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取评估详情失败: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"获取评估详情失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
509
backend/app/api/v1/admin.py
Normal file
509
backend/app/api/v1/admin.py
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
"""
|
||||||
|
管理员相关API路由
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
|
from app.core.deps import get_current_active_user as get_current_user, get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.course import Course, CourseStatus
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/stats")
|
||||||
|
async def get_dashboard_stats(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取管理员仪表盘统计数据
|
||||||
|
|
||||||
|
需要管理员权限
|
||||||
|
"""
|
||||||
|
# 权限检查
|
||||||
|
if current_user.role != "admin":
|
||||||
|
return ResponseModel(
|
||||||
|
code=403,
|
||||||
|
message="权限不足,需要管理员权限"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 用户统计
|
||||||
|
total_users = await db.scalar(select(func.count(User.id)))
|
||||||
|
|
||||||
|
# 计算最近30天的新增用户
|
||||||
|
thirty_days_ago = datetime.now() - timedelta(days=30)
|
||||||
|
new_users_count = await db.scalar(
|
||||||
|
select(func.count(User.id))
|
||||||
|
.where(User.created_at >= thirty_days_ago)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 计算增长率(假设上个月也是30天)
|
||||||
|
sixty_days_ago = datetime.now() - timedelta(days=60)
|
||||||
|
last_month_users = await db.scalar(
|
||||||
|
select(func.count(User.id))
|
||||||
|
.where(User.created_at >= sixty_days_ago)
|
||||||
|
.where(User.created_at < thirty_days_ago)
|
||||||
|
)
|
||||||
|
|
||||||
|
growth_rate = 0.0
|
||||||
|
if last_month_users > 0:
|
||||||
|
growth_rate = ((new_users_count - last_month_users) / last_month_users) * 100
|
||||||
|
|
||||||
|
# 课程统计
|
||||||
|
total_courses = await db.scalar(
|
||||||
|
select(func.count(Course.id))
|
||||||
|
.where(Course.status == CourseStatus.PUBLISHED)
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: 完成的课程数需要根据用户课程进度表计算
|
||||||
|
completed_courses = 0 # 暂时设为0
|
||||||
|
|
||||||
|
# 考试统计(如果有考试表的话)
|
||||||
|
total_exams = 0
|
||||||
|
avg_score = 0.0
|
||||||
|
pass_rate = "0%"
|
||||||
|
|
||||||
|
# 学习时长统计(如果有学习记录表的话)
|
||||||
|
total_learning_hours = 0
|
||||||
|
avg_learning_hours = 0.0
|
||||||
|
active_rate = "0%"
|
||||||
|
|
||||||
|
# 构建响应数据
|
||||||
|
stats = {
|
||||||
|
"users": {
|
||||||
|
"total": total_users,
|
||||||
|
"growth": new_users_count,
|
||||||
|
"growthRate": f"{growth_rate:.1f}%"
|
||||||
|
},
|
||||||
|
"courses": {
|
||||||
|
"total": total_courses,
|
||||||
|
"completed": completed_courses,
|
||||||
|
"completionRate": f"{(completed_courses / total_courses * 100) if total_courses > 0 else 0:.1f}%"
|
||||||
|
},
|
||||||
|
"exams": {
|
||||||
|
"total": total_exams,
|
||||||
|
"avgScore": avg_score,
|
||||||
|
"passRate": pass_rate
|
||||||
|
},
|
||||||
|
"learning": {
|
||||||
|
"totalHours": total_learning_hours,
|
||||||
|
"avgHours": avg_learning_hours,
|
||||||
|
"activeRate": active_rate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取仪表盘统计数据成功",
|
||||||
|
data=stats
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/user-growth")
|
||||||
|
async def get_user_growth_data(
|
||||||
|
days: int = Query(30, description="统计天数", ge=7, le=90),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取用户增长数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: 统计天数,默认30天
|
||||||
|
|
||||||
|
需要管理员权限
|
||||||
|
"""
|
||||||
|
# 权限检查
|
||||||
|
if current_user.role != "admin":
|
||||||
|
return ResponseModel(
|
||||||
|
code=403,
|
||||||
|
message="权限不足,需要管理员权限"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 准备日期列表
|
||||||
|
dates = []
|
||||||
|
new_users = []
|
||||||
|
active_users = []
|
||||||
|
|
||||||
|
end_date = datetime.now().date()
|
||||||
|
|
||||||
|
for i in range(days):
|
||||||
|
current_date = end_date - timedelta(days=days-1-i)
|
||||||
|
dates.append(current_date.strftime("%Y-%m-%d"))
|
||||||
|
|
||||||
|
# 统计当天新增用户
|
||||||
|
next_date = current_date + timedelta(days=1)
|
||||||
|
new_count = await db.scalar(
|
||||||
|
select(func.count(User.id))
|
||||||
|
.where(func.date(User.created_at) == current_date)
|
||||||
|
)
|
||||||
|
new_users.append(new_count or 0)
|
||||||
|
|
||||||
|
# 统计当天活跃用户(有登录记录)
|
||||||
|
active_count = await db.scalar(
|
||||||
|
select(func.count(User.id))
|
||||||
|
.where(func.date(User.last_login_at) == current_date)
|
||||||
|
)
|
||||||
|
active_users.append(active_count or 0)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取用户增长数据成功",
|
||||||
|
data={
|
||||||
|
"dates": dates,
|
||||||
|
"newUsers": new_users,
|
||||||
|
"activeUsers": active_users
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/course-completion")
|
||||||
|
async def get_course_completion_data(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取课程完成率数据
|
||||||
|
|
||||||
|
需要管理员权限
|
||||||
|
"""
|
||||||
|
# 权限检查
|
||||||
|
if current_user.role != "admin":
|
||||||
|
return ResponseModel(
|
||||||
|
code=403,
|
||||||
|
message="权限不足,需要管理员权限"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取所有已发布的课程
|
||||||
|
courses_result = await db.execute(
|
||||||
|
select(Course.name, Course.id)
|
||||||
|
.where(Course.status == CourseStatus.PUBLISHED)
|
||||||
|
.order_by(Course.sort_order, Course.id)
|
||||||
|
.limit(10) # 限制显示前10个课程
|
||||||
|
)
|
||||||
|
courses = courses_result.all()
|
||||||
|
|
||||||
|
course_names = []
|
||||||
|
completion_rates = []
|
||||||
|
|
||||||
|
for course_name, course_id in courses:
|
||||||
|
course_names.append(course_name)
|
||||||
|
|
||||||
|
# TODO: 根据用户课程进度表计算完成率
|
||||||
|
# 这里暂时生成模拟数据
|
||||||
|
import random
|
||||||
|
completion_rate = random.randint(60, 95)
|
||||||
|
completion_rates.append(completion_rate)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取课程完成率数据成功",
|
||||||
|
data={
|
||||||
|
"courses": course_names,
|
||||||
|
"completionRates": completion_rates
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 岗位管理(最小可用 stub 版本)=====
|
||||||
|
|
||||||
|
def _ensure_admin(user: User) -> Optional[ResponseModel]:
|
||||||
|
if user.role != "admin":
|
||||||
|
return ResponseModel(code=403, message="权限不足,需要管理员权限")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# 注意:positions相关路由已移至positions.py
|
||||||
|
# _sample_positions函数和所有positions路由已删除,避免与positions.py冲突
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 用户批量操作 =====
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.models.position_member import PositionMember
|
||||||
|
|
||||||
|
|
||||||
|
class BatchUserOperation(BaseModel):
|
||||||
|
"""批量用户操作请求模型"""
|
||||||
|
ids: List[int]
|
||||||
|
action: str # delete, activate, deactivate, change_role, assign_position, assign_team
|
||||||
|
value: Optional[Any] = None # 角色值、岗位ID、团队ID等
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/batch", response_model=ResponseModel)
|
||||||
|
async def batch_user_operation(
|
||||||
|
operation: BatchUserOperation,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
批量用户操作
|
||||||
|
|
||||||
|
支持的操作类型:
|
||||||
|
- delete: 批量删除用户(软删除)
|
||||||
|
- activate: 批量启用用户
|
||||||
|
- deactivate: 批量禁用用户
|
||||||
|
- change_role: 批量修改角色(需要 value 参数)
|
||||||
|
- assign_position: 批量分配岗位(需要 value 参数为岗位ID)
|
||||||
|
- assign_team: 批量分配团队(需要 value 参数为团队ID)
|
||||||
|
|
||||||
|
权限:需要管理员权限
|
||||||
|
"""
|
||||||
|
# 权限检查
|
||||||
|
if current_user.role != "admin":
|
||||||
|
return ResponseModel(
|
||||||
|
code=403,
|
||||||
|
message="权限不足,需要管理员权限"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not operation.ids:
|
||||||
|
return ResponseModel(
|
||||||
|
code=400,
|
||||||
|
message="请选择要操作的用户"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 不能操作自己
|
||||||
|
if current_user.id in operation.ids:
|
||||||
|
return ResponseModel(
|
||||||
|
code=400,
|
||||||
|
message="不能对自己执行批量操作"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取要操作的用户
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.id.in_(operation.ids), User.is_deleted == False)
|
||||||
|
)
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
return ResponseModel(
|
||||||
|
code=404,
|
||||||
|
message="未找到要操作的用户"
|
||||||
|
)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if operation.action == "delete":
|
||||||
|
# 批量软删除
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
user.is_deleted = True
|
||||||
|
user.deleted_at = datetime.now()
|
||||||
|
success_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
failed_count += 1
|
||||||
|
errors.append(f"删除用户 {user.username} 失败: {str(e)}")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
elif operation.action == "activate":
|
||||||
|
# 批量启用
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
user.is_active = True
|
||||||
|
success_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
failed_count += 1
|
||||||
|
errors.append(f"启用用户 {user.username} 失败: {str(e)}")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
elif operation.action == "deactivate":
|
||||||
|
# 批量禁用
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
user.is_active = False
|
||||||
|
success_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
failed_count += 1
|
||||||
|
errors.append(f"禁用用户 {user.username} 失败: {str(e)}")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
elif operation.action == "change_role":
|
||||||
|
# 批量修改角色
|
||||||
|
if not operation.value:
|
||||||
|
return ResponseModel(
|
||||||
|
code=400,
|
||||||
|
message="请指定要修改的角色"
|
||||||
|
)
|
||||||
|
|
||||||
|
valid_roles = ["trainee", "manager", "admin"]
|
||||||
|
if operation.value not in valid_roles:
|
||||||
|
return ResponseModel(
|
||||||
|
code=400,
|
||||||
|
message=f"无效的角色,可选值: {', '.join(valid_roles)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
user.role = operation.value
|
||||||
|
success_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
failed_count += 1
|
||||||
|
errors.append(f"修改用户 {user.username} 角色失败: {str(e)}")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
elif operation.action == "assign_position":
|
||||||
|
# 批量分配岗位
|
||||||
|
if not operation.value:
|
||||||
|
return ResponseModel(
|
||||||
|
code=400,
|
||||||
|
message="请指定要分配的岗位ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
position_id = int(operation.value)
|
||||||
|
|
||||||
|
# 获取岗位信息用于通知
|
||||||
|
from app.models.position import Position
|
||||||
|
position_result = await db.execute(
|
||||||
|
select(Position).where(Position.id == position_id)
|
||||||
|
)
|
||||||
|
position = position_result.scalar_one_or_none()
|
||||||
|
position_name = position.name if position else "未知岗位"
|
||||||
|
|
||||||
|
# 记录新分配成功的用户ID(用于发送通知)
|
||||||
|
newly_assigned_user_ids = []
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
# 检查是否已有该岗位
|
||||||
|
existing = await db.execute(
|
||||||
|
select(PositionMember).where(
|
||||||
|
PositionMember.user_id == user.id,
|
||||||
|
PositionMember.position_id == position_id,
|
||||||
|
PositionMember.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
# 已有该岗位,跳过
|
||||||
|
success_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 添加岗位关联(PositionMember模型没有created_by字段)
|
||||||
|
member = PositionMember(
|
||||||
|
position_id=position_id,
|
||||||
|
user_id=user.id,
|
||||||
|
joined_at=datetime.now()
|
||||||
|
)
|
||||||
|
db.add(member)
|
||||||
|
newly_assigned_user_ids.append(user.id)
|
||||||
|
success_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
failed_count += 1
|
||||||
|
errors.append(f"为用户 {user.username} 分配岗位失败: {str(e)}")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# 发送岗位分配通知给新分配的用户
|
||||||
|
if newly_assigned_user_ids:
|
||||||
|
try:
|
||||||
|
from app.services.notification_service import notification_service
|
||||||
|
from app.schemas.notification import NotificationBatchCreate, NotificationType
|
||||||
|
|
||||||
|
notification_batch = NotificationBatchCreate(
|
||||||
|
user_ids=newly_assigned_user_ids,
|
||||||
|
title="岗位分配通知",
|
||||||
|
content=f"您已被分配到「{position_name}」岗位,请查看相关培训课程。",
|
||||||
|
type=NotificationType.POSITION_ASSIGN,
|
||||||
|
related_id=position_id,
|
||||||
|
related_type="position",
|
||||||
|
sender_id=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
await notification_service.batch_create_notifications(
|
||||||
|
db=db,
|
||||||
|
batch_in=notification_batch
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# 通知发送失败不影响岗位分配结果
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).error(f"发送岗位分配通知失败: {str(e)}")
|
||||||
|
|
||||||
|
elif operation.action == "assign_team":
|
||||||
|
# 批量分配团队
|
||||||
|
if not operation.value:
|
||||||
|
return ResponseModel(
|
||||||
|
code=400,
|
||||||
|
message="请指定要分配的团队ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.models.user import user_teams
|
||||||
|
|
||||||
|
team_id = int(operation.value)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
# 检查是否已在该团队
|
||||||
|
existing = await db.execute(
|
||||||
|
select(user_teams).where(
|
||||||
|
user_teams.c.user_id == user.id,
|
||||||
|
user_teams.c.team_id == team_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.first():
|
||||||
|
# 已在该团队,跳过
|
||||||
|
success_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 添加团队关联
|
||||||
|
await db.execute(
|
||||||
|
user_teams.insert().values(
|
||||||
|
user_id=user.id,
|
||||||
|
team_id=team_id,
|
||||||
|
role="member",
|
||||||
|
joined_at=datetime.now()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
success_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
failed_count += 1
|
||||||
|
errors.append(f"为用户 {user.username} 分配团队失败: {str(e)}")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
else:
|
||||||
|
return ResponseModel(
|
||||||
|
code=400,
|
||||||
|
message=f"不支持的操作类型: {operation.action}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 返回结果
|
||||||
|
action_names = {
|
||||||
|
"delete": "删除",
|
||||||
|
"activate": "启用",
|
||||||
|
"deactivate": "禁用",
|
||||||
|
"change_role": "修改角色",
|
||||||
|
"assign_position": "分配岗位",
|
||||||
|
"assign_team": "分配团队"
|
||||||
|
}
|
||||||
|
action_name = action_names.get(operation.action, operation.action)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message=f"批量{action_name}完成:成功 {success_count} 个,失败 {failed_count} 个",
|
||||||
|
data={
|
||||||
|
"success_count": success_count,
|
||||||
|
"failed_count": failed_count,
|
||||||
|
"errors": errors
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
return ResponseModel(
|
||||||
|
code=500,
|
||||||
|
message=f"批量操作失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
24
backend/app/api/v1/admin_portal/__init__.py
Normal file
24
backend/app/api/v1/admin_portal/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
SaaS 超级管理后台 API
|
||||||
|
|
||||||
|
提供租户管理、配置管理、提示词管理等功能
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from .auth import router as auth_router
|
||||||
|
from .tenants import router as tenants_router
|
||||||
|
from .configs import router as configs_router
|
||||||
|
from .prompts import router as prompts_router
|
||||||
|
from .features import router as features_router
|
||||||
|
|
||||||
|
# 创建管理后台主路由
|
||||||
|
router = APIRouter(prefix="/admin", tags=["管理后台"])
|
||||||
|
|
||||||
|
# 注册子路由
|
||||||
|
router.include_router(auth_router)
|
||||||
|
router.include_router(tenants_router)
|
||||||
|
router.include_router(configs_router)
|
||||||
|
router.include_router(prompts_router)
|
||||||
|
router.include_router(features_router)
|
||||||
|
|
||||||
277
backend/app/api/v1/admin_portal/auth.py
Normal file
277
backend/app/api/v1/admin_portal/auth.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"""
|
||||||
|
管理员认证 API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pymysql
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from .schemas import (
|
||||||
|
AdminLoginRequest,
|
||||||
|
AdminLoginResponse,
|
||||||
|
AdminUserInfo,
|
||||||
|
AdminChangePasswordRequest,
|
||||||
|
ResponseModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["管理员认证"])
|
||||||
|
|
||||||
|
# 密码加密
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
# JWT 配置
|
||||||
|
SECRET_KEY = os.getenv("ADMIN_JWT_SECRET", "admin-secret-key-kaopeilian-2026")
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
||||||
|
|
||||||
|
# 安全认证
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
# 管理库连接配置
|
||||||
|
ADMIN_DB_CONFIG = {
|
||||||
|
"host": os.getenv("ADMIN_DB_HOST", "prod-mysql"),
|
||||||
|
"port": int(os.getenv("ADMIN_DB_PORT", "3306")),
|
||||||
|
"user": os.getenv("ADMIN_DB_USER", "root"),
|
||||||
|
"password": os.getenv("ADMIN_DB_PASSWORD", "ProdMySQL2025!@#"),
|
||||||
|
"db": os.getenv("ADMIN_DB_NAME", "kaopeilian_admin"),
|
||||||
|
"charset": "utf8mb4",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
"""获取数据库连接"""
|
||||||
|
return pymysql.connect(**ADMIN_DB_CONFIG, cursorclass=pymysql.cursors.DictCursor)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""验证密码"""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
"""获取密码哈希"""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
"""创建访问令牌"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> dict:
|
||||||
|
"""解码访问令牌"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
return payload
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Token已过期",
|
||||||
|
)
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="无效的Token",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_admin(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||||
|
) -> AdminUserInfo:
|
||||||
|
"""获取当前登录的管理员"""
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = decode_access_token(token)
|
||||||
|
|
||||||
|
admin_id = payload.get("sub")
|
||||||
|
if not admin_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="无效的Token",
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, username, email, full_name, role, is_active, last_login_at
|
||||||
|
FROM admin_users WHERE id = %s
|
||||||
|
""",
|
||||||
|
(admin_id,)
|
||||||
|
)
|
||||||
|
admin = cursor.fetchone()
|
||||||
|
|
||||||
|
if not admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="管理员不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not admin["is_active"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="账户已被禁用",
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminUserInfo(
|
||||||
|
id=admin["id"],
|
||||||
|
username=admin["username"],
|
||||||
|
email=admin["email"],
|
||||||
|
full_name=admin["full_name"],
|
||||||
|
role=admin["role"],
|
||||||
|
last_login_at=admin["last_login_at"],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def require_superadmin(
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin)
|
||||||
|
) -> AdminUserInfo:
|
||||||
|
"""要求超级管理员权限"""
|
||||||
|
if admin.role != "superadmin":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="需要超级管理员权限",
|
||||||
|
)
|
||||||
|
return admin
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=AdminLoginResponse, summary="管理员登录")
|
||||||
|
async def admin_login(request: Request, login_data: AdminLoginRequest):
|
||||||
|
"""
|
||||||
|
管理员登录
|
||||||
|
|
||||||
|
- **username**: 用户名
|
||||||
|
- **password**: 密码
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 查询管理员
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, username, email, full_name, role, password_hash, is_active, last_login_at
|
||||||
|
FROM admin_users WHERE username = %s
|
||||||
|
""",
|
||||||
|
(login_data.username,)
|
||||||
|
)
|
||||||
|
admin = cursor.fetchone()
|
||||||
|
|
||||||
|
if not admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="用户名或密码错误",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not admin["is_active"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="账户已被禁用",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证密码
|
||||||
|
if not verify_password(login_data.password, admin["password_hash"]):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="用户名或密码错误",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新最后登录时间和IP
|
||||||
|
client_ip = request.client.host if request.client else None
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE admin_users
|
||||||
|
SET last_login_at = NOW(), last_login_ip = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(client_ip, admin["id"])
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 创建 Token
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": str(admin["id"]), "username": admin["username"], "role": admin["role"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminLoginResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
token_type="bearer",
|
||||||
|
expires_in=ACCESS_TOKEN_EXPIRE_HOURS * 3600,
|
||||||
|
admin_user=AdminUserInfo(
|
||||||
|
id=admin["id"],
|
||||||
|
username=admin["username"],
|
||||||
|
email=admin["email"],
|
||||||
|
full_name=admin["full_name"],
|
||||||
|
role=admin["role"],
|
||||||
|
last_login_at=datetime.now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=AdminUserInfo, summary="获取当前管理员信息")
|
||||||
|
async def get_me(admin: AdminUserInfo = Depends(get_current_admin)):
|
||||||
|
"""获取当前登录管理员的信息"""
|
||||||
|
return admin
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password", response_model=ResponseModel, summary="修改密码")
|
||||||
|
async def change_password(
|
||||||
|
data: AdminChangePasswordRequest,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
修改当前管理员密码
|
||||||
|
|
||||||
|
- **old_password**: 旧密码
|
||||||
|
- **new_password**: 新密码
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 验证旧密码
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT password_hash FROM admin_users WHERE id = %s",
|
||||||
|
(admin.id,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if not verify_password(data.old_password, row["password_hash"]):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="旧密码错误",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新密码
|
||||||
|
new_hash = get_password_hash(data.new_password)
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE admin_users SET password_hash = %s WHERE id = %s",
|
||||||
|
(new_hash, admin.id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return ResponseModel(message="密码修改成功")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", response_model=ResponseModel, summary="退出登录")
|
||||||
|
async def admin_logout(admin: AdminUserInfo = Depends(get_current_admin)):
|
||||||
|
"""退出登录(客户端需清除 Token)"""
|
||||||
|
return ResponseModel(message="退出成功")
|
||||||
|
|
||||||
480
backend/app/api/v1/admin_portal/configs.py
Normal file
480
backend/app/api/v1/admin_portal/configs.py
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
"""
|
||||||
|
配置管理 API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
|
||||||
|
from .auth import get_current_admin, get_db_connection, AdminUserInfo
|
||||||
|
from .schemas import (
|
||||||
|
ConfigTemplateResponse,
|
||||||
|
TenantConfigResponse,
|
||||||
|
TenantConfigCreate,
|
||||||
|
TenantConfigUpdate,
|
||||||
|
TenantConfigGroupResponse,
|
||||||
|
ConfigBatchUpdate,
|
||||||
|
ResponseModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/configs", tags=["配置管理"])
|
||||||
|
|
||||||
|
# 配置分组显示名称
|
||||||
|
CONFIG_GROUP_NAMES = {
|
||||||
|
"database": "数据库配置",
|
||||||
|
"redis": "Redis配置",
|
||||||
|
"security": "安全配置",
|
||||||
|
"coze": "Coze配置",
|
||||||
|
"ai": "AI服务配置",
|
||||||
|
"yanji": "言迹工牌配置",
|
||||||
|
"storage": "文件存储配置",
|
||||||
|
"basic": "基础配置",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def log_operation(cursor, admin: AdminUserInfo, tenant_id: int, tenant_code: str,
|
||||||
|
operation_type: str, resource_type: str, resource_id: int,
|
||||||
|
resource_name: str, old_value: dict = None, new_value: dict = None):
|
||||||
|
"""记录操作日志"""
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO operation_logs
|
||||||
|
(admin_user_id, admin_username, tenant_id, tenant_code, operation_type,
|
||||||
|
resource_type, resource_id, resource_name, old_value, new_value)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(admin.id, admin.username, tenant_id, tenant_code, operation_type,
|
||||||
|
resource_type, resource_id, resource_name,
|
||||||
|
json.dumps(old_value, ensure_ascii=False) if old_value else None,
|
||||||
|
json.dumps(new_value, ensure_ascii=False) if new_value else None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates", response_model=List[ConfigTemplateResponse], summary="获取配置模板")
|
||||||
|
async def get_config_templates(
|
||||||
|
config_group: Optional[str] = Query(None, description="配置分组筛选"),
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取配置模板列表
|
||||||
|
|
||||||
|
配置模板定义了所有可配置项的元数据
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
if config_group:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM config_templates
|
||||||
|
WHERE config_group = %s
|
||||||
|
ORDER BY sort_order, id
|
||||||
|
""",
|
||||||
|
(config_group,)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT * FROM config_templates ORDER BY config_group, sort_order, id"
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for row in rows:
|
||||||
|
# 解析 options 字段
|
||||||
|
options = None
|
||||||
|
if row.get("options"):
|
||||||
|
try:
|
||||||
|
options = json.loads(row["options"])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result.append(ConfigTemplateResponse(
|
||||||
|
id=row["id"],
|
||||||
|
config_group=row["config_group"],
|
||||||
|
config_key=row["config_key"],
|
||||||
|
display_name=row["display_name"],
|
||||||
|
description=row["description"],
|
||||||
|
value_type=row["value_type"],
|
||||||
|
default_value=row["default_value"],
|
||||||
|
is_required=row["is_required"],
|
||||||
|
is_secret=row["is_secret"],
|
||||||
|
options=options,
|
||||||
|
sort_order=row["sort_order"],
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/groups", response_model=List[Dict], summary="获取配置分组列表")
|
||||||
|
async def get_config_groups(admin: AdminUserInfo = Depends(get_current_admin)):
|
||||||
|
"""获取配置分组列表"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT config_group, COUNT(*) as count
|
||||||
|
FROM config_templates
|
||||||
|
GROUP BY config_group
|
||||||
|
ORDER BY config_group
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"group_name": row["config_group"],
|
||||||
|
"group_display_name": CONFIG_GROUP_NAMES.get(row["config_group"], row["config_group"]),
|
||||||
|
"config_count": row["count"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}", response_model=List[TenantConfigGroupResponse], summary="获取租户配置")
|
||||||
|
async def get_tenant_configs(
|
||||||
|
tenant_id: int,
|
||||||
|
config_group: Optional[str] = Query(None, description="配置分组筛选"),
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取租户的所有配置
|
||||||
|
|
||||||
|
返回按分组整理的配置列表,包含模板信息
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 验证租户存在
|
||||||
|
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
tenant = cursor.fetchone()
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="租户不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 查询配置模板和租户配置
|
||||||
|
group_filter = "AND ct.config_group = %s" if config_group else ""
|
||||||
|
params = [tenant_id, config_group] if config_group else [tenant_id]
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
ct.config_group,
|
||||||
|
ct.config_key,
|
||||||
|
ct.display_name,
|
||||||
|
ct.description,
|
||||||
|
ct.value_type,
|
||||||
|
ct.default_value,
|
||||||
|
ct.is_required,
|
||||||
|
ct.is_secret,
|
||||||
|
ct.sort_order,
|
||||||
|
tc.id as config_id,
|
||||||
|
tc.config_value,
|
||||||
|
tc.is_encrypted,
|
||||||
|
tc.created_at,
|
||||||
|
tc.updated_at
|
||||||
|
FROM config_templates ct
|
||||||
|
LEFT JOIN tenant_configs tc
|
||||||
|
ON tc.config_group = ct.config_group
|
||||||
|
AND tc.config_key = ct.config_key
|
||||||
|
AND tc.tenant_id = %s
|
||||||
|
WHERE 1=1 {group_filter}
|
||||||
|
ORDER BY ct.config_group, ct.sort_order, ct.id
|
||||||
|
""",
|
||||||
|
params
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
# 按分组整理
|
||||||
|
groups: Dict[str, List] = {}
|
||||||
|
for row in rows:
|
||||||
|
group = row["config_group"]
|
||||||
|
if group not in groups:
|
||||||
|
groups[group] = []
|
||||||
|
|
||||||
|
# 如果是敏感信息且有值,隐藏部分内容
|
||||||
|
config_value = row["config_value"]
|
||||||
|
if row["is_secret"] and config_value:
|
||||||
|
if len(config_value) > 8:
|
||||||
|
config_value = config_value[:4] + "****" + config_value[-4:]
|
||||||
|
else:
|
||||||
|
config_value = "****"
|
||||||
|
|
||||||
|
groups[group].append(TenantConfigResponse(
|
||||||
|
id=row["config_id"] or 0,
|
||||||
|
config_group=row["config_group"],
|
||||||
|
config_key=row["config_key"],
|
||||||
|
config_value=config_value if not row["is_secret"] else row["config_value"],
|
||||||
|
value_type=row["value_type"],
|
||||||
|
is_encrypted=row["is_encrypted"] or False,
|
||||||
|
description=row["description"],
|
||||||
|
created_at=row["created_at"] or None,
|
||||||
|
updated_at=row["updated_at"] or None,
|
||||||
|
display_name=row["display_name"],
|
||||||
|
is_required=row["is_required"],
|
||||||
|
is_secret=row["is_secret"],
|
||||||
|
))
|
||||||
|
|
||||||
|
return [
|
||||||
|
TenantConfigGroupResponse(
|
||||||
|
group_name=group,
|
||||||
|
group_display_name=CONFIG_GROUP_NAMES.get(group, group),
|
||||||
|
configs=configs,
|
||||||
|
)
|
||||||
|
for group, configs in groups.items()
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tenants/{tenant_id}/{config_group}/{config_key}", response_model=ResponseModel, summary="更新单个配置")
|
||||||
|
async def update_tenant_config(
|
||||||
|
tenant_id: int,
|
||||||
|
config_group: str,
|
||||||
|
config_key: str,
|
||||||
|
data: TenantConfigUpdate,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""更新租户的单个配置项"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 验证租户存在
|
||||||
|
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
tenant = cursor.fetchone()
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="租户不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证配置模板存在
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT value_type, is_secret FROM config_templates
|
||||||
|
WHERE config_group = %s AND config_key = %s
|
||||||
|
""",
|
||||||
|
(config_group, config_key)
|
||||||
|
)
|
||||||
|
template = cursor.fetchone()
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="无效的配置项",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查是否已有配置
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, config_value FROM tenant_configs
|
||||||
|
WHERE tenant_id = %s AND config_group = %s AND config_key = %s
|
||||||
|
""",
|
||||||
|
(tenant_id, config_group, config_key)
|
||||||
|
)
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# 更新
|
||||||
|
old_value = existing["config_value"]
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tenant_configs
|
||||||
|
SET config_value = %s, is_encrypted = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(data.config_value, template["is_secret"], existing["id"])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 插入
|
||||||
|
old_value = None
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tenant_configs
|
||||||
|
(tenant_id, config_group, config_key, config_value, value_type, is_encrypted)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(tenant_id, config_group, config_key, data.config_value,
|
||||||
|
template["value_type"], template["is_secret"])
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录操作日志
|
||||||
|
log_operation(
|
||||||
|
cursor, admin, tenant_id, tenant["code"],
|
||||||
|
"update", "config", tenant_id, f"{config_group}.{config_key}",
|
||||||
|
old_value={"value": old_value} if old_value else None,
|
||||||
|
new_value={"value": data.config_value}
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return ResponseModel(message="配置已更新")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tenants/{tenant_id}/batch", response_model=ResponseModel, summary="批量更新配置")
|
||||||
|
async def batch_update_tenant_configs(
|
||||||
|
tenant_id: int,
|
||||||
|
data: ConfigBatchUpdate,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""批量更新租户配置"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 验证租户存在
|
||||||
|
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
tenant = cursor.fetchone()
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="租户不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
for config in data.configs:
|
||||||
|
# 获取模板信息
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT value_type, is_secret FROM config_templates
|
||||||
|
WHERE config_group = %s AND config_key = %s
|
||||||
|
""",
|
||||||
|
(config.config_group, config.config_key)
|
||||||
|
)
|
||||||
|
template = cursor.fetchone()
|
||||||
|
if not template:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查是否已有配置
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM tenant_configs
|
||||||
|
WHERE tenant_id = %s AND config_group = %s AND config_key = %s
|
||||||
|
""",
|
||||||
|
(tenant_id, config.config_group, config.config_key)
|
||||||
|
)
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tenant_configs
|
||||||
|
SET config_value = %s, is_encrypted = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(config.config_value, template["is_secret"], existing["id"])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tenant_configs
|
||||||
|
(tenant_id, config_group, config_key, config_value, value_type, is_encrypted)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(tenant_id, config.config_group, config.config_key, config.config_value,
|
||||||
|
template["value_type"], template["is_secret"])
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
# 记录操作日志
|
||||||
|
log_operation(
|
||||||
|
cursor, admin, tenant_id, tenant["code"],
|
||||||
|
"batch_update", "config", tenant_id, f"批量更新 {updated_count} 项配置"
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return ResponseModel(message=f"已更新 {updated_count} 项配置")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tenants/{tenant_id}/{config_group}/{config_key}", response_model=ResponseModel, summary="删除配置")
|
||||||
|
async def delete_tenant_config(
|
||||||
|
tenant_id: int,
|
||||||
|
config_group: str,
|
||||||
|
config_key: str,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""删除租户的配置项(恢复为默认值)"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 验证租户存在
|
||||||
|
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
tenant = cursor.fetchone()
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="租户不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 删除配置
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM tenant_configs
|
||||||
|
WHERE tenant_id = %s AND config_group = %s AND config_key = %s
|
||||||
|
""",
|
||||||
|
(tenant_id, config_group, config_key)
|
||||||
|
)
|
||||||
|
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="配置不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录操作日志
|
||||||
|
log_operation(
|
||||||
|
cursor, admin, tenant_id, tenant["code"],
|
||||||
|
"delete", "config", tenant_id, f"{config_group}.{config_key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return ResponseModel(message="配置已删除,将使用默认值")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/refresh-cache", response_model=ResponseModel, summary="刷新配置缓存")
|
||||||
|
async def refresh_tenant_config_cache(
|
||||||
|
tenant_id: int,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""刷新租户的配置缓存"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 获取租户编码
|
||||||
|
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
tenant = cursor.fetchone()
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="租户不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 刷新缓存
|
||||||
|
try:
|
||||||
|
from app.core.config import DynamicConfig
|
||||||
|
import asyncio
|
||||||
|
asyncio.create_task(DynamicConfig.refresh_cache(tenant["code"]))
|
||||||
|
except Exception as e:
|
||||||
|
pass # 缓存刷新失败不影响主流程
|
||||||
|
|
||||||
|
return ResponseModel(message="缓存刷新请求已发送")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
424
backend/app/api/v1/admin_portal/features.py
Normal file
424
backend/app/api/v1/admin_portal/features.py
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
"""
|
||||||
|
功能开关管理 API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
|
||||||
|
from .auth import get_current_admin, get_db_connection, AdminUserInfo
|
||||||
|
from .schemas import (
|
||||||
|
FeatureSwitchCreate,
|
||||||
|
FeatureSwitchUpdate,
|
||||||
|
FeatureSwitchResponse,
|
||||||
|
FeatureSwitchGroupResponse,
|
||||||
|
ResponseModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/features", tags=["功能开关"])
|
||||||
|
|
||||||
|
# 功能分组显示名称
|
||||||
|
FEATURE_GROUP_NAMES = {
|
||||||
|
"exam": "考试模块",
|
||||||
|
"practice": "陪练模块",
|
||||||
|
"broadcast": "播课模块",
|
||||||
|
"course": "课程模块",
|
||||||
|
"yanji": "智能工牌模块",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def log_operation(cursor, admin: AdminUserInfo, tenant_id: int, tenant_code: str,
|
||||||
|
operation_type: str, resource_type: str, resource_id: int,
|
||||||
|
resource_name: str, old_value: dict = None, new_value: dict = None):
|
||||||
|
"""记录操作日志"""
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO operation_logs
|
||||||
|
(admin_user_id, admin_username, tenant_id, tenant_code, operation_type,
|
||||||
|
resource_type, resource_id, resource_name, old_value, new_value)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(admin.id, admin.username, tenant_id, tenant_code, operation_type,
|
||||||
|
resource_type, resource_id, resource_name,
|
||||||
|
json.dumps(old_value, ensure_ascii=False) if old_value else None,
|
||||||
|
json.dumps(new_value, ensure_ascii=False) if new_value else None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/defaults", response_model=List[FeatureSwitchGroupResponse], summary="获取默认功能开关")
|
||||||
|
async def get_default_features(
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""获取全局默认的功能开关配置"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM feature_switches
|
||||||
|
WHERE tenant_id IS NULL
|
||||||
|
ORDER BY feature_group, id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
# 按分组整理
|
||||||
|
groups: Dict[str, List] = {}
|
||||||
|
for row in rows:
|
||||||
|
group = row["feature_group"] or "other"
|
||||||
|
if group not in groups:
|
||||||
|
groups[group] = []
|
||||||
|
|
||||||
|
config = None
|
||||||
|
if row.get("config"):
|
||||||
|
try:
|
||||||
|
config = json.loads(row["config"])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
groups[group].append(FeatureSwitchResponse(
|
||||||
|
id=row["id"],
|
||||||
|
tenant_id=row["tenant_id"],
|
||||||
|
feature_code=row["feature_code"],
|
||||||
|
feature_name=row["feature_name"],
|
||||||
|
feature_group=row["feature_group"],
|
||||||
|
is_enabled=row["is_enabled"],
|
||||||
|
config=config,
|
||||||
|
description=row["description"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
updated_at=row["updated_at"],
|
||||||
|
))
|
||||||
|
|
||||||
|
return [
|
||||||
|
FeatureSwitchGroupResponse(
|
||||||
|
group_name=group,
|
||||||
|
group_display_name=FEATURE_GROUP_NAMES.get(group, group),
|
||||||
|
features=features,
|
||||||
|
)
|
||||||
|
for group, features in groups.items()
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}", response_model=List[FeatureSwitchGroupResponse], summary="获取租户功能开关")
|
||||||
|
async def get_tenant_features(
|
||||||
|
tenant_id: int,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取租户的功能开关配置
|
||||||
|
|
||||||
|
返回租户自定义配置和默认配置的合并结果
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 验证租户存在
|
||||||
|
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
tenant = cursor.fetchone()
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="租户不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取默认配置
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM feature_switches
|
||||||
|
WHERE tenant_id IS NULL
|
||||||
|
ORDER BY feature_group, id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
default_rows = cursor.fetchall()
|
||||||
|
|
||||||
|
# 获取租户配置
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM feature_switches
|
||||||
|
WHERE tenant_id = %s
|
||||||
|
""",
|
||||||
|
(tenant_id,)
|
||||||
|
)
|
||||||
|
tenant_rows = cursor.fetchall()
|
||||||
|
|
||||||
|
# 合并配置
|
||||||
|
tenant_features = {row["feature_code"]: row for row in tenant_rows}
|
||||||
|
|
||||||
|
groups: Dict[str, List] = {}
|
||||||
|
for row in default_rows:
|
||||||
|
group = row["feature_group"] or "other"
|
||||||
|
if group not in groups:
|
||||||
|
groups[group] = []
|
||||||
|
|
||||||
|
# 使用租户配置覆盖默认配置
|
||||||
|
effective_row = tenant_features.get(row["feature_code"], row)
|
||||||
|
|
||||||
|
config = None
|
||||||
|
if effective_row.get("config"):
|
||||||
|
try:
|
||||||
|
config = json.loads(effective_row["config"])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
groups[group].append(FeatureSwitchResponse(
|
||||||
|
id=effective_row["id"],
|
||||||
|
tenant_id=effective_row["tenant_id"],
|
||||||
|
feature_code=effective_row["feature_code"],
|
||||||
|
feature_name=effective_row["feature_name"],
|
||||||
|
feature_group=effective_row["feature_group"],
|
||||||
|
is_enabled=effective_row["is_enabled"],
|
||||||
|
config=config,
|
||||||
|
description=effective_row["description"],
|
||||||
|
created_at=effective_row["created_at"],
|
||||||
|
updated_at=effective_row["updated_at"],
|
||||||
|
))
|
||||||
|
|
||||||
|
return [
|
||||||
|
FeatureSwitchGroupResponse(
|
||||||
|
group_name=group,
|
||||||
|
group_display_name=FEATURE_GROUP_NAMES.get(group, group),
|
||||||
|
features=features,
|
||||||
|
)
|
||||||
|
for group, features in groups.items()
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tenants/{tenant_id}/{feature_code}", response_model=ResponseModel, summary="更新租户功能开关")
|
||||||
|
async def update_tenant_feature(
|
||||||
|
tenant_id: int,
|
||||||
|
feature_code: str,
|
||||||
|
data: FeatureSwitchUpdate,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""更新租户的功能开关"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 验证租户存在
|
||||||
|
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
tenant = cursor.fetchone()
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="租户不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取默认配置
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM feature_switches
|
||||||
|
WHERE tenant_id IS NULL AND feature_code = %s
|
||||||
|
""",
|
||||||
|
(feature_code,)
|
||||||
|
)
|
||||||
|
default_feature = cursor.fetchone()
|
||||||
|
|
||||||
|
if not default_feature:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="无效的功能编码",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查租户是否已有配置
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, is_enabled FROM feature_switches
|
||||||
|
WHERE tenant_id = %s AND feature_code = %s
|
||||||
|
""",
|
||||||
|
(tenant_id, feature_code)
|
||||||
|
)
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# 更新
|
||||||
|
old_enabled = existing["is_enabled"]
|
||||||
|
|
||||||
|
update_fields = []
|
||||||
|
update_values = []
|
||||||
|
|
||||||
|
if data.is_enabled is not None:
|
||||||
|
update_fields.append("is_enabled = %s")
|
||||||
|
update_values.append(data.is_enabled)
|
||||||
|
|
||||||
|
if data.config is not None:
|
||||||
|
update_fields.append("config = %s")
|
||||||
|
update_values.append(json.dumps(data.config))
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
update_values.append(existing["id"])
|
||||||
|
cursor.execute(
|
||||||
|
f"UPDATE feature_switches SET {', '.join(update_fields)} WHERE id = %s",
|
||||||
|
update_values
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 创建租户配置
|
||||||
|
old_enabled = default_feature["is_enabled"]
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO feature_switches
|
||||||
|
(tenant_id, feature_code, feature_name, feature_group, is_enabled, config, description)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(tenant_id, feature_code, default_feature["feature_name"],
|
||||||
|
default_feature["feature_group"],
|
||||||
|
data.is_enabled if data.is_enabled is not None else default_feature["is_enabled"],
|
||||||
|
json.dumps(data.config) if data.config else default_feature["config"],
|
||||||
|
default_feature["description"])
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录操作日志
|
||||||
|
log_operation(
|
||||||
|
cursor, admin, tenant_id, tenant["code"],
|
||||||
|
"update", "feature", tenant_id, feature_code,
|
||||||
|
old_value={"is_enabled": old_enabled},
|
||||||
|
new_value={"is_enabled": data.is_enabled, "config": data.config}
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
status_text = "启用" if data.is_enabled else "禁用"
|
||||||
|
return ResponseModel(message=f"功能 {default_feature['feature_name']} 已{status_text}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tenants/{tenant_id}/{feature_code}", response_model=ResponseModel, summary="重置租户功能开关")
|
||||||
|
async def reset_tenant_feature(
|
||||||
|
tenant_id: int,
|
||||||
|
feature_code: str,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""重置租户的功能开关为默认值"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 验证租户存在
|
||||||
|
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
tenant = cursor.fetchone()
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="租户不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 删除租户配置
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM feature_switches
|
||||||
|
WHERE tenant_id = %s AND feature_code = %s
|
||||||
|
""",
|
||||||
|
(tenant_id, feature_code)
|
||||||
|
)
|
||||||
|
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
return ResponseModel(message="功能配置已是默认值")
|
||||||
|
|
||||||
|
# 记录操作日志
|
||||||
|
log_operation(
|
||||||
|
cursor, admin, tenant_id, tenant["code"],
|
||||||
|
"reset", "feature", tenant_id, feature_code
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return ResponseModel(message="功能配置已重置为默认值")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/batch", response_model=ResponseModel, summary="批量更新功能开关")
|
||||||
|
async def batch_update_tenant_features(
|
||||||
|
tenant_id: int,
|
||||||
|
features: List[Dict],
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
批量更新租户的功能开关
|
||||||
|
|
||||||
|
请求体格式:
|
||||||
|
[
|
||||||
|
{"feature_code": "exam_module", "is_enabled": true},
|
||||||
|
{"feature_code": "practice_voice", "is_enabled": false}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 验证租户存在
|
||||||
|
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
tenant = cursor.fetchone()
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="租户不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
for feature in features:
|
||||||
|
feature_code = feature.get("feature_code")
|
||||||
|
is_enabled = feature.get("is_enabled")
|
||||||
|
|
||||||
|
if not feature_code or is_enabled is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 获取默认配置
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM feature_switches
|
||||||
|
WHERE tenant_id IS NULL AND feature_code = %s
|
||||||
|
""",
|
||||||
|
(feature_code,)
|
||||||
|
)
|
||||||
|
default_feature = cursor.fetchone()
|
||||||
|
|
||||||
|
if not default_feature:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查租户是否已有配置
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM feature_switches
|
||||||
|
WHERE tenant_id = %s AND feature_code = %s
|
||||||
|
""",
|
||||||
|
(tenant_id, feature_code)
|
||||||
|
)
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE feature_switches SET is_enabled = %s WHERE id = %s",
|
||||||
|
(is_enabled, existing["id"])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO feature_switches
|
||||||
|
(tenant_id, feature_code, feature_name, feature_group, is_enabled, description)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(tenant_id, feature_code, default_feature["feature_name"],
|
||||||
|
default_feature["feature_group"], is_enabled, default_feature["description"])
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
# 记录操作日志
|
||||||
|
log_operation(
|
||||||
|
cursor, admin, tenant_id, tenant["code"],
|
||||||
|
"batch_update", "feature", tenant_id, f"批量更新 {updated_count} 项功能开关"
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return ResponseModel(message=f"已更新 {updated_count} 项功能开关")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
637
backend/app/api/v1/admin_portal/prompts.py
Normal file
637
backend/app/api/v1/admin_portal/prompts.py
Normal file
@@ -0,0 +1,637 @@
|
|||||||
|
"""
|
||||||
|
AI 提示词管理 API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
|
||||||
|
from .auth import get_current_admin, require_superadmin, get_db_connection, AdminUserInfo
|
||||||
|
from .schemas import (
|
||||||
|
AIPromptCreate,
|
||||||
|
AIPromptUpdate,
|
||||||
|
AIPromptResponse,
|
||||||
|
AIPromptVersionResponse,
|
||||||
|
TenantPromptResponse,
|
||||||
|
TenantPromptUpdate,
|
||||||
|
ResponseModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/prompts", tags=["提示词管理"])
|
||||||
|
|
||||||
|
|
||||||
|
def log_operation(cursor, admin: AdminUserInfo, tenant_id: int, tenant_code: str,
|
||||||
|
operation_type: str, resource_type: str, resource_id: int,
|
||||||
|
resource_name: str, old_value: dict = None, new_value: dict = None):
|
||||||
|
"""记录操作日志"""
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO operation_logs
|
||||||
|
(admin_user_id, admin_username, tenant_id, tenant_code, operation_type,
|
||||||
|
resource_type, resource_id, resource_name, old_value, new_value)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(admin.id, admin.username, tenant_id, tenant_code, operation_type,
|
||||||
|
resource_type, resource_id, resource_name,
|
||||||
|
json.dumps(old_value, ensure_ascii=False) if old_value else None,
|
||||||
|
json.dumps(new_value, ensure_ascii=False) if new_value else None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[AIPromptResponse], summary="获取提示词列表")
|
||||||
|
async def list_prompts(
|
||||||
|
module: Optional[str] = Query(None, description="模块筛选"),
|
||||||
|
is_active: Optional[bool] = Query(None, description="是否启用"),
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取所有 AI 提示词模板
|
||||||
|
|
||||||
|
- **module**: 模块筛选(course, exam, practice, ability)
|
||||||
|
- **is_active**: 是否启用
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
conditions = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if module:
|
||||||
|
conditions.append("module = %s")
|
||||||
|
params.append(module)
|
||||||
|
|
||||||
|
if is_active is not None:
|
||||||
|
conditions.append("is_active = %s")
|
||||||
|
params.append(is_active)
|
||||||
|
|
||||||
|
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
f"""
|
||||||
|
SELECT * FROM ai_prompts
|
||||||
|
WHERE {where_clause}
|
||||||
|
ORDER BY module, id
|
||||||
|
""",
|
||||||
|
params
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for row in rows:
|
||||||
|
# 解析 JSON 字段
|
||||||
|
variables = None
|
||||||
|
if row.get("variables"):
|
||||||
|
try:
|
||||||
|
variables = json.loads(row["variables"])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
output_schema = None
|
||||||
|
if row.get("output_schema"):
|
||||||
|
try:
|
||||||
|
output_schema = json.loads(row["output_schema"])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result.append(AIPromptResponse(
|
||||||
|
id=row["id"],
|
||||||
|
code=row["code"],
|
||||||
|
name=row["name"],
|
||||||
|
description=row["description"],
|
||||||
|
module=row["module"],
|
||||||
|
system_prompt=row["system_prompt"],
|
||||||
|
user_prompt_template=row["user_prompt_template"],
|
||||||
|
variables=variables,
|
||||||
|
output_schema=output_schema,
|
||||||
|
model_recommendation=row["model_recommendation"],
|
||||||
|
max_tokens=row["max_tokens"],
|
||||||
|
temperature=float(row["temperature"]) if row["temperature"] else 0.7,
|
||||||
|
is_system=row["is_system"],
|
||||||
|
is_active=row["is_active"],
|
||||||
|
version=row["version"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
updated_at=row["updated_at"],
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{prompt_id}", response_model=AIPromptResponse, summary="获取提示词详情")
|
||||||
|
async def get_prompt(
|
||||||
|
prompt_id: int,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""获取提示词详情"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT * FROM ai_prompts WHERE id = %s", (prompt_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="提示词不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 解析 JSON 字段
|
||||||
|
variables = None
|
||||||
|
if row.get("variables"):
|
||||||
|
try:
|
||||||
|
variables = json.loads(row["variables"])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
output_schema = None
|
||||||
|
if row.get("output_schema"):
|
||||||
|
try:
|
||||||
|
output_schema = json.loads(row["output_schema"])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return AIPromptResponse(
|
||||||
|
id=row["id"],
|
||||||
|
code=row["code"],
|
||||||
|
name=row["name"],
|
||||||
|
description=row["description"],
|
||||||
|
module=row["module"],
|
||||||
|
system_prompt=row["system_prompt"],
|
||||||
|
user_prompt_template=row["user_prompt_template"],
|
||||||
|
variables=variables,
|
||||||
|
output_schema=output_schema,
|
||||||
|
model_recommendation=row["model_recommendation"],
|
||||||
|
max_tokens=row["max_tokens"],
|
||||||
|
temperature=float(row["temperature"]) if row["temperature"] else 0.7,
|
||||||
|
is_system=row["is_system"],
|
||||||
|
is_active=row["is_active"],
|
||||||
|
version=row["version"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
updated_at=row["updated_at"],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=AIPromptResponse, summary="创建提示词")
|
||||||
|
async def create_prompt(
|
||||||
|
data: AIPromptCreate,
|
||||||
|
admin: AdminUserInfo = Depends(require_superadmin),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
创建新的提示词模板
|
||||||
|
|
||||||
|
需要超级管理员权限
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 检查编码是否已存在
|
||||||
|
cursor.execute("SELECT id FROM ai_prompts WHERE code = %s", (data.code,))
|
||||||
|
if cursor.fetchone():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="提示词编码已存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建提示词
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ai_prompts
|
||||||
|
(code, name, description, module, system_prompt, user_prompt_template,
|
||||||
|
variables, output_schema, model_recommendation, max_tokens, temperature,
|
||||||
|
is_system, created_by)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, %s)
|
||||||
|
""",
|
||||||
|
(data.code, data.name, data.description, data.module,
|
||||||
|
data.system_prompt, data.user_prompt_template,
|
||||||
|
json.dumps(data.variables) if data.variables else None,
|
||||||
|
json.dumps(data.output_schema) if data.output_schema else None,
|
||||||
|
data.model_recommendation, data.max_tokens, data.temperature,
|
||||||
|
admin.id)
|
||||||
|
)
|
||||||
|
prompt_id = cursor.lastrowid
|
||||||
|
|
||||||
|
# 记录操作日志
|
||||||
|
log_operation(
|
||||||
|
cursor, admin, None, None,
|
||||||
|
"create", "prompt", prompt_id, data.name,
|
||||||
|
new_value=data.model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return await get_prompt(prompt_id, admin)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{prompt_id}", response_model=AIPromptResponse, summary="更新提示词")
|
||||||
|
async def update_prompt(
|
||||||
|
prompt_id: int,
|
||||||
|
data: AIPromptUpdate,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
更新提示词模板
|
||||||
|
|
||||||
|
更新会自动保存版本历史
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 获取原提示词
|
||||||
|
cursor.execute("SELECT * FROM ai_prompts WHERE id = %s", (prompt_id,))
|
||||||
|
old_prompt = cursor.fetchone()
|
||||||
|
|
||||||
|
if not old_prompt:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="提示词不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 保存版本历史(如果系统提示词或用户提示词有变化)
|
||||||
|
if data.system_prompt or data.user_prompt_template:
|
||||||
|
new_version = old_prompt["version"] + 1
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ai_prompt_versions
|
||||||
|
(prompt_id, version, system_prompt, user_prompt_template, variables,
|
||||||
|
output_schema, change_summary, created_by)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(prompt_id, old_prompt["version"],
|
||||||
|
old_prompt["system_prompt"], old_prompt["user_prompt_template"],
|
||||||
|
old_prompt["variables"], old_prompt["output_schema"],
|
||||||
|
f"版本 {old_prompt['version']} 备份",
|
||||||
|
admin.id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
new_version = old_prompt["version"]
|
||||||
|
|
||||||
|
# 构建更新语句
|
||||||
|
update_fields = []
|
||||||
|
update_values = []
|
||||||
|
|
||||||
|
if data.name is not None:
|
||||||
|
update_fields.append("name = %s")
|
||||||
|
update_values.append(data.name)
|
||||||
|
|
||||||
|
if data.description is not None:
|
||||||
|
update_fields.append("description = %s")
|
||||||
|
update_values.append(data.description)
|
||||||
|
|
||||||
|
if data.system_prompt is not None:
|
||||||
|
update_fields.append("system_prompt = %s")
|
||||||
|
update_values.append(data.system_prompt)
|
||||||
|
|
||||||
|
if data.user_prompt_template is not None:
|
||||||
|
update_fields.append("user_prompt_template = %s")
|
||||||
|
update_values.append(data.user_prompt_template)
|
||||||
|
|
||||||
|
if data.variables is not None:
|
||||||
|
update_fields.append("variables = %s")
|
||||||
|
update_values.append(json.dumps(data.variables))
|
||||||
|
|
||||||
|
if data.output_schema is not None:
|
||||||
|
update_fields.append("output_schema = %s")
|
||||||
|
update_values.append(json.dumps(data.output_schema))
|
||||||
|
|
||||||
|
if data.model_recommendation is not None:
|
||||||
|
update_fields.append("model_recommendation = %s")
|
||||||
|
update_values.append(data.model_recommendation)
|
||||||
|
|
||||||
|
if data.max_tokens is not None:
|
||||||
|
update_fields.append("max_tokens = %s")
|
||||||
|
update_values.append(data.max_tokens)
|
||||||
|
|
||||||
|
if data.temperature is not None:
|
||||||
|
update_fields.append("temperature = %s")
|
||||||
|
update_values.append(data.temperature)
|
||||||
|
|
||||||
|
if data.is_active is not None:
|
||||||
|
update_fields.append("is_active = %s")
|
||||||
|
update_values.append(data.is_active)
|
||||||
|
|
||||||
|
if not update_fields:
|
||||||
|
return await get_prompt(prompt_id, admin)
|
||||||
|
|
||||||
|
# 更新版本号
|
||||||
|
if data.system_prompt or data.user_prompt_template:
|
||||||
|
update_fields.append("version = %s")
|
||||||
|
update_values.append(new_version)
|
||||||
|
|
||||||
|
update_fields.append("updated_by = %s")
|
||||||
|
update_values.append(admin.id)
|
||||||
|
update_values.append(prompt_id)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
f"UPDATE ai_prompts SET {', '.join(update_fields)} WHERE id = %s",
|
||||||
|
update_values
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录操作日志
|
||||||
|
log_operation(
|
||||||
|
cursor, admin, None, None,
|
||||||
|
"update", "prompt", prompt_id, old_prompt["name"],
|
||||||
|
old_value={"version": old_prompt["version"]},
|
||||||
|
new_value=data.model_dump(exclude_unset=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return await get_prompt(prompt_id, admin)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{prompt_id}/versions", response_model=List[AIPromptVersionResponse], summary="获取提示词版本历史")
|
||||||
|
async def get_prompt_versions(
|
||||||
|
prompt_id: int,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""获取提示词的版本历史"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM ai_prompt_versions
|
||||||
|
WHERE prompt_id = %s
|
||||||
|
ORDER BY version DESC
|
||||||
|
""",
|
||||||
|
(prompt_id,)
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for row in rows:
|
||||||
|
variables = None
|
||||||
|
if row.get("variables"):
|
||||||
|
try:
|
||||||
|
variables = json.loads(row["variables"])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result.append(AIPromptVersionResponse(
|
||||||
|
id=row["id"],
|
||||||
|
prompt_id=row["prompt_id"],
|
||||||
|
version=row["version"],
|
||||||
|
system_prompt=row["system_prompt"],
|
||||||
|
user_prompt_template=row["user_prompt_template"],
|
||||||
|
variables=variables,
|
||||||
|
change_summary=row["change_summary"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{prompt_id}/rollback/{version}", response_model=AIPromptResponse, summary="回滚提示词版本")
|
||||||
|
async def rollback_prompt_version(
|
||||||
|
prompt_id: int,
|
||||||
|
version: int,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""回滚到指定版本的提示词"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 获取指定版本
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM ai_prompt_versions
|
||||||
|
WHERE prompt_id = %s AND version = %s
|
||||||
|
""",
|
||||||
|
(prompt_id, version)
|
||||||
|
)
|
||||||
|
version_row = cursor.fetchone()
|
||||||
|
|
||||||
|
if not version_row:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="版本不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取当前提示词
|
||||||
|
cursor.execute("SELECT * FROM ai_prompts WHERE id = %s", (prompt_id,))
|
||||||
|
current = cursor.fetchone()
|
||||||
|
|
||||||
|
if not current:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="提示词不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 保存当前版本到历史
|
||||||
|
new_version = current["version"] + 1
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ai_prompt_versions
|
||||||
|
(prompt_id, version, system_prompt, user_prompt_template, variables,
|
||||||
|
output_schema, change_summary, created_by)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(prompt_id, current["version"],
|
||||||
|
current["system_prompt"], current["user_prompt_template"],
|
||||||
|
current["variables"], current["output_schema"],
|
||||||
|
f"回滚前备份(版本 {current['version']})",
|
||||||
|
admin.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 回滚
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET system_prompt = %s, user_prompt_template = %s, variables = %s,
|
||||||
|
output_schema = %s, version = %s, updated_by = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(version_row["system_prompt"], version_row["user_prompt_template"],
|
||||||
|
version_row["variables"], version_row["output_schema"],
|
||||||
|
new_version, admin.id, prompt_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录操作日志
|
||||||
|
log_operation(
|
||||||
|
cursor, admin, None, None,
|
||||||
|
"rollback", "prompt", prompt_id, current["name"],
|
||||||
|
old_value={"version": current["version"]},
|
||||||
|
new_value={"version": new_version, "rollback_from": version}
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return await get_prompt(prompt_id, admin)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}", response_model=List[TenantPromptResponse], summary="获取租户自定义提示词")
|
||||||
|
async def get_tenant_prompts(
|
||||||
|
tenant_id: int,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""获取租户的自定义提示词列表"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT tp.*, ap.code as prompt_code, ap.name as prompt_name
|
||||||
|
FROM tenant_prompts tp
|
||||||
|
JOIN ai_prompts ap ON tp.prompt_id = ap.id
|
||||||
|
WHERE tp.tenant_id = %s
|
||||||
|
ORDER BY ap.module, ap.id
|
||||||
|
""",
|
||||||
|
(tenant_id,)
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
TenantPromptResponse(
|
||||||
|
id=row["id"],
|
||||||
|
tenant_id=row["tenant_id"],
|
||||||
|
prompt_id=row["prompt_id"],
|
||||||
|
prompt_code=row["prompt_code"],
|
||||||
|
prompt_name=row["prompt_name"],
|
||||||
|
system_prompt=row["system_prompt"],
|
||||||
|
user_prompt_template=row["user_prompt_template"],
|
||||||
|
is_active=row["is_active"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
updated_at=row["updated_at"],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tenants/{tenant_id}/{prompt_id}", response_model=ResponseModel, summary="更新租户自定义提示词")
|
||||||
|
async def update_tenant_prompt(
|
||||||
|
tenant_id: int,
|
||||||
|
prompt_id: int,
|
||||||
|
data: TenantPromptUpdate,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""创建或更新租户的自定义提示词"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 验证租户存在
|
||||||
|
cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
tenant = cursor.fetchone()
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="租户不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证提示词存在
|
||||||
|
cursor.execute("SELECT name FROM ai_prompts WHERE id = %s", (prompt_id,))
|
||||||
|
prompt = cursor.fetchone()
|
||||||
|
if not prompt:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="提示词不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查是否已有自定义
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM tenant_prompts
|
||||||
|
WHERE tenant_id = %s AND prompt_id = %s
|
||||||
|
""",
|
||||||
|
(tenant_id, prompt_id)
|
||||||
|
)
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# 更新
|
||||||
|
update_fields = []
|
||||||
|
update_values = []
|
||||||
|
|
||||||
|
if data.system_prompt is not None:
|
||||||
|
update_fields.append("system_prompt = %s")
|
||||||
|
update_values.append(data.system_prompt)
|
||||||
|
|
||||||
|
if data.user_prompt_template is not None:
|
||||||
|
update_fields.append("user_prompt_template = %s")
|
||||||
|
update_values.append(data.user_prompt_template)
|
||||||
|
|
||||||
|
if data.is_active is not None:
|
||||||
|
update_fields.append("is_active = %s")
|
||||||
|
update_values.append(data.is_active)
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
update_fields.append("updated_by = %s")
|
||||||
|
update_values.append(admin.id)
|
||||||
|
update_values.append(existing["id"])
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
f"UPDATE tenant_prompts SET {', '.join(update_fields)} WHERE id = %s",
|
||||||
|
update_values
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 创建
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tenant_prompts
|
||||||
|
(tenant_id, prompt_id, system_prompt, user_prompt_template, is_active, created_by)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(tenant_id, prompt_id, data.system_prompt, data.user_prompt_template,
|
||||||
|
data.is_active if data.is_active is not None else True, admin.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录操作日志
|
||||||
|
log_operation(
|
||||||
|
cursor, admin, tenant_id, tenant["code"],
|
||||||
|
"update", "tenant_prompt", prompt_id, prompt["name"],
|
||||||
|
new_value=data.model_dump(exclude_unset=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return ResponseModel(message="自定义提示词已保存")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tenants/{tenant_id}/{prompt_id}", response_model=ResponseModel, summary="删除租户自定义提示词")
|
||||||
|
async def delete_tenant_prompt(
|
||||||
|
tenant_id: int,
|
||||||
|
prompt_id: int,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""删除租户的自定义提示词(恢复使用默认)"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM tenant_prompts
|
||||||
|
WHERE tenant_id = %s AND prompt_id = %s
|
||||||
|
""",
|
||||||
|
(tenant_id, prompt_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="自定义提示词不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return ResponseModel(message="自定义提示词已删除,将使用默认模板")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
352
backend/app/api/v1/admin_portal/schemas.py
Normal file
352
backend/app/api/v1/admin_portal/schemas.py
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
"""
|
||||||
|
管理后台数据模型
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List, Any, Dict
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 通用模型
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
class ResponseModel(BaseModel):
|
||||||
|
"""通用响应模型"""
|
||||||
|
code: int = 0
|
||||||
|
message: str = "success"
|
||||||
|
data: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationParams(BaseModel):
|
||||||
|
"""分页参数"""
|
||||||
|
page: int = Field(default=1, ge=1)
|
||||||
|
page_size: int = Field(default=20, ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedResponse(BaseModel):
|
||||||
|
"""分页响应"""
|
||||||
|
items: List[Any]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 认证相关
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
class AdminLoginRequest(BaseModel):
|
||||||
|
"""管理员登录请求"""
|
||||||
|
username: str = Field(..., min_length=1, max_length=50)
|
||||||
|
password: str = Field(..., min_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLoginResponse(BaseModel):
|
||||||
|
"""管理员登录响应"""
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int
|
||||||
|
admin_user: "AdminUserInfo"
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserInfo(BaseModel):
|
||||||
|
"""管理员信息"""
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: Optional[str]
|
||||||
|
full_name: Optional[str]
|
||||||
|
role: str
|
||||||
|
last_login_at: Optional[datetime]
|
||||||
|
|
||||||
|
|
||||||
|
class AdminChangePasswordRequest(BaseModel):
|
||||||
|
"""修改密码请求"""
|
||||||
|
old_password: str = Field(..., min_length=6)
|
||||||
|
new_password: str = Field(..., min_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 租户相关
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
class TenantBase(BaseModel):
|
||||||
|
"""租户基础信息"""
|
||||||
|
code: str = Field(..., min_length=2, max_length=20, pattern=r'^[a-z0-9_]+$')
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
display_name: Optional[str] = Field(None, max_length=200)
|
||||||
|
domain: str = Field(..., min_length=1, max_length=200)
|
||||||
|
logo_url: Optional[str] = None
|
||||||
|
favicon_url: Optional[str] = None
|
||||||
|
contact_name: Optional[str] = None
|
||||||
|
contact_phone: Optional[str] = None
|
||||||
|
contact_email: Optional[str] = None
|
||||||
|
industry: str = Field(default="medical_beauty")
|
||||||
|
remarks: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TenantCreate(TenantBase):
|
||||||
|
"""创建租户请求"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TenantUpdate(BaseModel):
|
||||||
|
"""更新租户请求"""
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
|
display_name: Optional[str] = Field(None, max_length=200)
|
||||||
|
domain: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||||
|
logo_url: Optional[str] = None
|
||||||
|
favicon_url: Optional[str] = None
|
||||||
|
contact_name: Optional[str] = None
|
||||||
|
contact_phone: Optional[str] = None
|
||||||
|
contact_email: Optional[str] = None
|
||||||
|
industry: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
expire_at: Optional[datetime] = None
|
||||||
|
remarks: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TenantResponse(TenantBase):
|
||||||
|
"""租户响应"""
|
||||||
|
id: int
|
||||||
|
status: str
|
||||||
|
expire_at: Optional[datetime]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
config_count: int = 0 # 配置项数量
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TenantListResponse(BaseModel):
|
||||||
|
"""租户列表响应"""
|
||||||
|
items: List[TenantResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 配置相关
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
class ConfigTemplateResponse(BaseModel):
|
||||||
|
"""配置模板响应"""
|
||||||
|
id: int
|
||||||
|
config_group: str
|
||||||
|
config_key: str
|
||||||
|
display_name: str
|
||||||
|
description: Optional[str]
|
||||||
|
value_type: str
|
||||||
|
default_value: Optional[str]
|
||||||
|
is_required: bool
|
||||||
|
is_secret: bool
|
||||||
|
options: Optional[List[str]]
|
||||||
|
sort_order: int
|
||||||
|
|
||||||
|
|
||||||
|
class TenantConfigBase(BaseModel):
|
||||||
|
"""租户配置基础"""
|
||||||
|
config_group: str
|
||||||
|
config_key: str
|
||||||
|
config_value: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TenantConfigCreate(TenantConfigBase):
|
||||||
|
"""创建租户配置请求"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TenantConfigUpdate(BaseModel):
|
||||||
|
"""更新租户配置请求"""
|
||||||
|
config_value: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TenantConfigResponse(TenantConfigBase):
|
||||||
|
"""租户配置响应"""
|
||||||
|
id: int
|
||||||
|
value_type: str
|
||||||
|
is_encrypted: bool
|
||||||
|
description: Optional[str]
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
# 从模板获取的额外信息
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
is_required: bool = False
|
||||||
|
is_secret: bool = False
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TenantConfigGroupResponse(BaseModel):
|
||||||
|
"""租户配置分组响应"""
|
||||||
|
group_name: str
|
||||||
|
group_display_name: str
|
||||||
|
configs: List[TenantConfigResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigBatchUpdate(BaseModel):
|
||||||
|
"""批量更新配置请求"""
|
||||||
|
configs: List[TenantConfigCreate]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 提示词相关
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
class AIPromptBase(BaseModel):
|
||||||
|
"""AI提示词基础"""
|
||||||
|
code: str = Field(..., min_length=1, max_length=50)
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
description: Optional[str] = None
|
||||||
|
module: str
|
||||||
|
system_prompt: str
|
||||||
|
user_prompt_template: Optional[str] = None
|
||||||
|
variables: Optional[List[str]] = None
|
||||||
|
output_schema: Optional[Dict] = None
|
||||||
|
model_recommendation: Optional[str] = None
|
||||||
|
max_tokens: int = 4096
|
||||||
|
temperature: float = 0.7
|
||||||
|
|
||||||
|
|
||||||
|
class AIPromptCreate(AIPromptBase):
|
||||||
|
"""创建提示词请求"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AIPromptUpdate(BaseModel):
|
||||||
|
"""更新提示词请求"""
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
|
description: Optional[str] = None
|
||||||
|
system_prompt: Optional[str] = None
|
||||||
|
user_prompt_template: Optional[str] = None
|
||||||
|
variables: Optional[List[str]] = None
|
||||||
|
output_schema: Optional[Dict] = None
|
||||||
|
model_recommendation: Optional[str] = None
|
||||||
|
max_tokens: Optional[int] = None
|
||||||
|
temperature: Optional[float] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AIPromptResponse(AIPromptBase):
|
||||||
|
"""提示词响应"""
|
||||||
|
id: int
|
||||||
|
is_system: bool
|
||||||
|
is_active: bool
|
||||||
|
version: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AIPromptVersionResponse(BaseModel):
|
||||||
|
"""提示词版本响应"""
|
||||||
|
id: int
|
||||||
|
prompt_id: int
|
||||||
|
version: int
|
||||||
|
system_prompt: str
|
||||||
|
user_prompt_template: Optional[str]
|
||||||
|
variables: Optional[List[str]]
|
||||||
|
change_summary: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TenantPromptResponse(BaseModel):
|
||||||
|
"""租户自定义提示词响应"""
|
||||||
|
id: int
|
||||||
|
tenant_id: int
|
||||||
|
prompt_id: int
|
||||||
|
prompt_code: str
|
||||||
|
prompt_name: str
|
||||||
|
system_prompt: Optional[str]
|
||||||
|
user_prompt_template: Optional[str]
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TenantPromptUpdate(BaseModel):
|
||||||
|
"""更新租户自定义提示词"""
|
||||||
|
system_prompt: Optional[str] = None
|
||||||
|
user_prompt_template: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 功能开关相关
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
class FeatureSwitchBase(BaseModel):
|
||||||
|
"""功能开关基础"""
|
||||||
|
feature_code: str
|
||||||
|
feature_name: str
|
||||||
|
feature_group: Optional[str] = None
|
||||||
|
is_enabled: bool = True
|
||||||
|
config: Optional[Dict] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureSwitchCreate(FeatureSwitchBase):
|
||||||
|
"""创建功能开关请求"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureSwitchUpdate(BaseModel):
|
||||||
|
"""更新功能开关请求"""
|
||||||
|
is_enabled: Optional[bool] = None
|
||||||
|
config: Optional[Dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureSwitchResponse(FeatureSwitchBase):
|
||||||
|
"""功能开关响应"""
|
||||||
|
id: int
|
||||||
|
tenant_id: Optional[int]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureSwitchGroupResponse(BaseModel):
|
||||||
|
"""功能开关分组响应"""
|
||||||
|
group_name: str
|
||||||
|
group_display_name: str
|
||||||
|
features: List[FeatureSwitchResponse]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 操作日志相关
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
class OperationLogResponse(BaseModel):
|
||||||
|
"""操作日志响应"""
|
||||||
|
id: int
|
||||||
|
admin_username: Optional[str]
|
||||||
|
tenant_code: Optional[str]
|
||||||
|
operation_type: str
|
||||||
|
resource_type: str
|
||||||
|
resource_name: Optional[str]
|
||||||
|
old_value: Optional[Dict]
|
||||||
|
new_value: Optional[Dict]
|
||||||
|
ip_address: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# 更新前向引用
|
||||||
|
AdminLoginResponse.model_rebuild()
|
||||||
|
|
||||||
379
backend/app/api/v1/admin_portal/tenants.py
Normal file
379
backend/app/api/v1/admin_portal/tenants.py
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
"""
|
||||||
|
租户管理 API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
|
||||||
|
from .auth import get_current_admin, require_superadmin, get_db_connection, AdminUserInfo
|
||||||
|
from .schemas import (
|
||||||
|
TenantCreate,
|
||||||
|
TenantUpdate,
|
||||||
|
TenantResponse,
|
||||||
|
TenantListResponse,
|
||||||
|
ResponseModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tenants", tags=["租户管理"])
|
||||||
|
|
||||||
|
|
||||||
|
def log_operation(cursor, admin: AdminUserInfo, tenant_id: int, tenant_code: str,
|
||||||
|
operation_type: str, resource_type: str, resource_id: int,
|
||||||
|
resource_name: str, old_value: dict = None, new_value: dict = None):
|
||||||
|
"""记录操作日志"""
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO operation_logs
|
||||||
|
(admin_user_id, admin_username, tenant_id, tenant_code, operation_type,
|
||||||
|
resource_type, resource_id, resource_name, old_value, new_value)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(admin.id, admin.username, tenant_id, tenant_code, operation_type,
|
||||||
|
resource_type, resource_id, resource_name,
|
||||||
|
json.dumps(old_value, ensure_ascii=False) if old_value else None,
|
||||||
|
json.dumps(new_value, ensure_ascii=False) if new_value else None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=TenantListResponse, summary="获取租户列表")
|
||||||
|
async def list_tenants(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
status: Optional[str] = Query(None, description="状态筛选"),
|
||||||
|
keyword: Optional[str] = Query(None, description="关键词搜索"),
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取租户列表
|
||||||
|
|
||||||
|
- **page**: 页码
|
||||||
|
- **page_size**: 每页数量
|
||||||
|
- **status**: 状态筛选(active, inactive, suspended)
|
||||||
|
- **keyword**: 关键词搜索(匹配名称、编码、域名)
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 构建查询条件
|
||||||
|
conditions = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if status:
|
||||||
|
conditions.append("t.status = %s")
|
||||||
|
params.append(status)
|
||||||
|
|
||||||
|
if keyword:
|
||||||
|
conditions.append("(t.name LIKE %s OR t.code LIKE %s OR t.domain LIKE %s)")
|
||||||
|
params.extend([f"%{keyword}%"] * 3)
|
||||||
|
|
||||||
|
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||||
|
|
||||||
|
# 查询总数
|
||||||
|
cursor.execute(
|
||||||
|
f"SELECT COUNT(*) as total FROM tenants t WHERE {where_clause}",
|
||||||
|
params
|
||||||
|
)
|
||||||
|
total = cursor.fetchone()["total"]
|
||||||
|
|
||||||
|
# 查询列表
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
cursor.execute(
|
||||||
|
f"""
|
||||||
|
SELECT t.*,
|
||||||
|
(SELECT COUNT(*) FROM tenant_configs tc WHERE tc.tenant_id = t.id) as config_count
|
||||||
|
FROM tenants t
|
||||||
|
WHERE {where_clause}
|
||||||
|
ORDER BY t.id DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""",
|
||||||
|
params + [page_size, offset]
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
items = [TenantResponse(**row) for row in rows]
|
||||||
|
|
||||||
|
return TenantListResponse(
|
||||||
|
items=items,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tenant_id}", response_model=TenantResponse, summary="获取租户详情")
|
||||||
|
async def get_tenant(
|
||||||
|
tenant_id: int,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""获取租户详情"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT t.*,
|
||||||
|
(SELECT COUNT(*) FROM tenant_configs tc WHERE tc.tenant_id = t.id) as config_count
|
||||||
|
FROM tenants t
|
||||||
|
WHERE t.id = %s
|
||||||
|
""",
|
||||||
|
(tenant_id,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="租户不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
return TenantResponse(**row)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TenantResponse, summary="创建租户")
|
||||||
|
async def create_tenant(
|
||||||
|
data: TenantCreate,
|
||||||
|
admin: AdminUserInfo = Depends(require_superadmin),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
创建新租户
|
||||||
|
|
||||||
|
需要超级管理员权限
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 检查编码是否已存在
|
||||||
|
cursor.execute("SELECT id FROM tenants WHERE code = %s", (data.code,))
|
||||||
|
if cursor.fetchone():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="租户编码已存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查域名是否已存在
|
||||||
|
cursor.execute("SELECT id FROM tenants WHERE domain = %s", (data.domain,))
|
||||||
|
if cursor.fetchone():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="域名已被使用",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建租户
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tenants
|
||||||
|
(code, name, display_name, domain, logo_url, favicon_url,
|
||||||
|
contact_name, contact_phone, contact_email, industry, remarks, created_by)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(data.code, data.name, data.display_name, data.domain,
|
||||||
|
data.logo_url, data.favicon_url, data.contact_name,
|
||||||
|
data.contact_phone, data.contact_email, data.industry,
|
||||||
|
data.remarks, admin.id)
|
||||||
|
)
|
||||||
|
tenant_id = cursor.lastrowid
|
||||||
|
|
||||||
|
# 记录操作日志
|
||||||
|
log_operation(
|
||||||
|
cursor, admin, tenant_id, data.code,
|
||||||
|
"create", "tenant", tenant_id, data.name,
|
||||||
|
new_value=data.model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 返回创建的租户
|
||||||
|
return await get_tenant(tenant_id, admin)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{tenant_id}", response_model=TenantResponse, summary="更新租户")
|
||||||
|
async def update_tenant(
|
||||||
|
tenant_id: int,
|
||||||
|
data: TenantUpdate,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""更新租户信息"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 获取原租户信息
|
||||||
|
cursor.execute("SELECT * FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
old_tenant = cursor.fetchone()
|
||||||
|
|
||||||
|
if not old_tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="租户不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 如果更新域名,检查是否已被使用
|
||||||
|
if data.domain and data.domain != old_tenant["domain"]:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM tenants WHERE domain = %s AND id != %s",
|
||||||
|
(data.domain, tenant_id)
|
||||||
|
)
|
||||||
|
if cursor.fetchone():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="域名已被使用",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建更新语句
|
||||||
|
update_fields = []
|
||||||
|
update_values = []
|
||||||
|
|
||||||
|
for field, value in data.model_dump(exclude_unset=True).items():
|
||||||
|
if value is not None:
|
||||||
|
update_fields.append(f"{field} = %s")
|
||||||
|
update_values.append(value)
|
||||||
|
|
||||||
|
if not update_fields:
|
||||||
|
return await get_tenant(tenant_id, admin)
|
||||||
|
|
||||||
|
update_fields.append("updated_by = %s")
|
||||||
|
update_values.append(admin.id)
|
||||||
|
update_values.append(tenant_id)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
f"UPDATE tenants SET {', '.join(update_fields)} WHERE id = %s",
|
||||||
|
update_values
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录操作日志
|
||||||
|
log_operation(
|
||||||
|
cursor, admin, tenant_id, old_tenant["code"],
|
||||||
|
"update", "tenant", tenant_id, old_tenant["name"],
|
||||||
|
old_value=dict(old_tenant),
|
||||||
|
new_value=data.model_dump(exclude_unset=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return await get_tenant(tenant_id, admin)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tenant_id}", response_model=ResponseModel, summary="删除租户")
|
||||||
|
async def delete_tenant(
|
||||||
|
tenant_id: int,
|
||||||
|
admin: AdminUserInfo = Depends(require_superadmin),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除租户
|
||||||
|
|
||||||
|
需要超级管理员权限
|
||||||
|
警告:此操作将删除租户及其所有配置
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# 获取租户信息
|
||||||
|
cursor.execute("SELECT * FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
tenant = cursor.fetchone()
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="租户不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录操作日志
|
||||||
|
log_operation(
|
||||||
|
cursor, admin, tenant_id, tenant["code"],
|
||||||
|
"delete", "tenant", tenant_id, tenant["name"],
|
||||||
|
old_value=dict(tenant)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 删除租户(级联删除配置)
|
||||||
|
cursor.execute("DELETE FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return ResponseModel(message=f"租户 {tenant['name']} 已删除")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tenant_id}/enable", response_model=ResponseModel, summary="启用租户")
|
||||||
|
async def enable_tenant(
|
||||||
|
tenant_id: int,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""启用租户"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE tenants SET status = 'active', updated_by = %s WHERE id = %s",
|
||||||
|
(admin.id, tenant_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="租户不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取租户信息并记录日志
|
||||||
|
cursor.execute("SELECT code, name FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
tenant = cursor.fetchone()
|
||||||
|
|
||||||
|
log_operation(
|
||||||
|
cursor, admin, tenant_id, tenant["code"],
|
||||||
|
"enable", "tenant", tenant_id, tenant["name"]
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return ResponseModel(message="租户已启用")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tenant_id}/disable", response_model=ResponseModel, summary="禁用租户")
|
||||||
|
async def disable_tenant(
|
||||||
|
tenant_id: int,
|
||||||
|
admin: AdminUserInfo = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""禁用租户"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE tenants SET status = 'inactive', updated_by = %s WHERE id = %s",
|
||||||
|
(admin.id, tenant_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="租户不存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取租户信息并记录日志
|
||||||
|
cursor.execute("SELECT code, name FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
tenant = cursor.fetchone()
|
||||||
|
|
||||||
|
log_operation(
|
||||||
|
cursor, admin, tenant_id, tenant["code"],
|
||||||
|
"disable", "tenant", tenant_id, tenant["name"]
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return ResponseModel(message="租户已禁用")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
158
backend/app/api/v1/admin_positions_backup.py
Normal file
158
backend/app/api/v1/admin_positions_backup.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# 此文件备份了admin.py中的positions相关路由代码
|
||||||
|
# 这些路由已移至positions.py,为避免冲突,从admin.py中移除
|
||||||
|
|
||||||
|
@router.get("/positions")
|
||||||
|
async def list_positions(
|
||||||
|
keyword: Optional[str] = Query(None, description="关键词"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
pageSize: int = Query(20, ge=1, le=100),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
_db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取岗位列表(stub 数据)
|
||||||
|
|
||||||
|
返回结构兼容前端:data.list/total/page/pageSize
|
||||||
|
"""
|
||||||
|
not_admin = _ensure_admin(current_user)
|
||||||
|
if not_admin:
|
||||||
|
return not_admin
|
||||||
|
|
||||||
|
try:
|
||||||
|
items = _sample_positions()
|
||||||
|
if keyword:
|
||||||
|
kw = keyword.lower()
|
||||||
|
items = [
|
||||||
|
p for p in items if kw in (p.get("name", "") + p.get("description", "")).lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
total = len(items)
|
||||||
|
start = (page - 1) * pageSize
|
||||||
|
end = start + pageSize
|
||||||
|
page_items = items[start:end]
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取岗位列表成功",
|
||||||
|
data={
|
||||||
|
"list": page_items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"pageSize": pageSize,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# 记录错误堆栈由全局异常中间件处理;此处返回统一结构
|
||||||
|
return ResponseModel(code=500, message=f"服务器错误:{exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/positions/tree")
|
||||||
|
async def get_position_tree(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
_db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取岗位树(stub 数据)
|
||||||
|
"""
|
||||||
|
not_admin = _ensure_admin(current_user)
|
||||||
|
if not_admin:
|
||||||
|
return not_admin
|
||||||
|
|
||||||
|
try:
|
||||||
|
items = _sample_positions()
|
||||||
|
id_to_node: Dict[int, Dict[str, Any]] = {}
|
||||||
|
for p in items:
|
||||||
|
node = {**p, "children": []}
|
||||||
|
id_to_node[p["id"]] = node
|
||||||
|
|
||||||
|
roots: List[Dict[str, Any]] = []
|
||||||
|
for p in items:
|
||||||
|
parent_id = p.get("parentId")
|
||||||
|
if parent_id and parent_id in id_to_node:
|
||||||
|
id_to_node[parent_id]["children"].append(id_to_node[p["id"]])
|
||||||
|
else:
|
||||||
|
roots.append(id_to_node[p["id"]])
|
||||||
|
|
||||||
|
return ResponseModel(code=200, message="获取岗位树成功", data=roots)
|
||||||
|
except Exception as exc:
|
||||||
|
return ResponseModel(code=500, message=f"服务器错误:{exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/positions/{position_id}")
|
||||||
|
async def get_position_detail(
|
||||||
|
position_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
_db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
not_admin = _ensure_admin(current_user)
|
||||||
|
if not_admin:
|
||||||
|
return not_admin
|
||||||
|
|
||||||
|
items = _sample_positions()
|
||||||
|
for p in items:
|
||||||
|
if p["id"] == position_id:
|
||||||
|
return ResponseModel(code=200, message="获取岗位详情成功", data=p)
|
||||||
|
return ResponseModel(code=404, message="岗位不存在")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/positions/{position_id}/check-delete")
|
||||||
|
async def check_position_delete(
|
||||||
|
position_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
_db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
not_admin = _ensure_admin(current_user)
|
||||||
|
if not_admin:
|
||||||
|
return not_admin
|
||||||
|
|
||||||
|
# stub:允许删除非根岗位
|
||||||
|
deletable = position_id != 1
|
||||||
|
reason = "根岗位不允许删除" if not deletable else ""
|
||||||
|
return ResponseModel(code=200, message="检查成功", data={"deletable": deletable, "reason": reason})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/positions")
|
||||||
|
async def create_position(
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
_db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
not_admin = _ensure_admin(current_user)
|
||||||
|
if not_admin:
|
||||||
|
return not_admin
|
||||||
|
|
||||||
|
# stub:直接回显并附带一个伪ID
|
||||||
|
payload = dict(payload)
|
||||||
|
payload.setdefault("id", 999)
|
||||||
|
payload.setdefault("createTime", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
|
return ResponseModel(code=200, message="创建岗位成功", data=payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/positions/{position_id}")
|
||||||
|
async def update_position(
|
||||||
|
position_id: int,
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
_db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
not_admin = _ensure_admin(current_user)
|
||||||
|
if not_admin:
|
||||||
|
return not_admin
|
||||||
|
|
||||||
|
# stub:直接回显
|
||||||
|
updated = {"id": position_id, **payload}
|
||||||
|
return ResponseModel(code=200, message="更新岗位成功", data=updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/positions/{position_id}")
|
||||||
|
async def delete_position(
|
||||||
|
position_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
_db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
not_admin = _ensure_admin(current_user)
|
||||||
|
if not_admin:
|
||||||
|
return not_admin
|
||||||
|
|
||||||
|
# stub:直接返回成功
|
||||||
|
return ResponseModel(code=200, message="删除岗位成功", data={"id": position_id})
|
||||||
156
backend/app/api/v1/auth.py
Normal file
156
backend/app/api/v1/auth.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""
|
||||||
|
认证 API
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, status, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_current_active_user, get_db
|
||||||
|
from app.core.logger import logger
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.auth import LoginRequest, RefreshTokenRequest, Token
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
from app.schemas.user import User as UserSchema
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
from app.services.system_log_service import system_log_service
|
||||||
|
from app.schemas.system_log import SystemLogCreate
|
||||||
|
from app.core.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=ResponseModel)
|
||||||
|
async def login(
|
||||||
|
login_data: LoginRequest,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
用户登录
|
||||||
|
|
||||||
|
支持使用用户名、邮箱或手机号登录
|
||||||
|
"""
|
||||||
|
auth_service = AuthService(db)
|
||||||
|
try:
|
||||||
|
user, token = await auth_service.login(
|
||||||
|
username=login_data.username,
|
||||||
|
password=login_data.password,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录登录成功日志
|
||||||
|
await system_log_service.create_log(
|
||||||
|
db,
|
||||||
|
SystemLogCreate(
|
||||||
|
level="INFO",
|
||||||
|
type="security",
|
||||||
|
message=f"用户 {user.username} 登录成功",
|
||||||
|
user_id=user.id,
|
||||||
|
user=user.username,
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
path="/api/v1/auth/login",
|
||||||
|
method="POST",
|
||||||
|
user_agent=request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
message="登录成功",
|
||||||
|
data={
|
||||||
|
"user": UserSchema.model_validate(user).model_dump(),
|
||||||
|
"token": token.model_dump(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except UnauthorizedError as e:
|
||||||
|
# 记录登录失败日志
|
||||||
|
await system_log_service.create_log(
|
||||||
|
db,
|
||||||
|
SystemLogCreate(
|
||||||
|
level="WARNING",
|
||||||
|
type="security",
|
||||||
|
message=f"用户 {login_data.username} 登录失败:密码错误",
|
||||||
|
user=login_data.username,
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
path="/api/v1/auth/login",
|
||||||
|
method="POST",
|
||||||
|
user_agent=request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 不返回 401,统一返回 HTTP 200 + 业务失败码,便于前端友好提示
|
||||||
|
logger.warning("login_failed_wrong_credentials", username=login_data.username)
|
||||||
|
return ResponseModel(
|
||||||
|
code=400,
|
||||||
|
message=str(e) or "用户名或密码错误",
|
||||||
|
data=None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("login_failed_unexpected", error=str(e))
|
||||||
|
return ResponseModel(
|
||||||
|
code=500,
|
||||||
|
message="登录失败,请稍后重试",
|
||||||
|
data=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=ResponseModel)
|
||||||
|
async def refresh_token(
|
||||||
|
refresh_data: RefreshTokenRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
刷新访问令牌
|
||||||
|
|
||||||
|
使用刷新令牌获取新的访问令牌
|
||||||
|
"""
|
||||||
|
auth_service = AuthService(db)
|
||||||
|
token = await auth_service.refresh_token(refresh_data.refresh_token)
|
||||||
|
|
||||||
|
return ResponseModel(message="令牌刷新成功", data=token.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", response_model=ResponseModel)
|
||||||
|
async def logout(
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
用户登出
|
||||||
|
|
||||||
|
注意:客户端需要删除本地存储的令牌
|
||||||
|
"""
|
||||||
|
auth_service = AuthService(db)
|
||||||
|
await auth_service.logout(current_user.id)
|
||||||
|
|
||||||
|
# 记录登出日志
|
||||||
|
await system_log_service.create_log(
|
||||||
|
db,
|
||||||
|
SystemLogCreate(
|
||||||
|
level="INFO",
|
||||||
|
type="security",
|
||||||
|
message=f"用户 {current_user.username} 登出",
|
||||||
|
user_id=current_user.id,
|
||||||
|
user=current_user.username,
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
path="/api/v1/auth/logout",
|
||||||
|
method="POST",
|
||||||
|
user_agent=request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(message="登出成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/verify", response_model=ResponseModel)
|
||||||
|
async def verify_token(
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
验证令牌
|
||||||
|
|
||||||
|
用于检查当前令牌是否有效
|
||||||
|
"""
|
||||||
|
return ResponseModel(
|
||||||
|
message="令牌有效",
|
||||||
|
data={
|
||||||
|
"user": UserSchema.model_validate(current_user).model_dump(),
|
||||||
|
},
|
||||||
|
)
|
||||||
145
backend/app/api/v1/broadcast.py
Normal file
145
backend/app/api/v1/broadcast.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
播课功能 API 接口
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_db, get_current_user, require_admin_or_manager
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
from app.models.course import Course
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.coze_broadcast_service import broadcast_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# Schema 定义
|
||||||
|
class GenerateBroadcastResponse(BaseModel):
|
||||||
|
"""生成播课响应"""
|
||||||
|
message: str = Field(..., description="提示信息")
|
||||||
|
|
||||||
|
|
||||||
|
class BroadcastInfo(BaseModel):
|
||||||
|
"""播课信息"""
|
||||||
|
has_broadcast: bool = Field(..., description="是否有播课")
|
||||||
|
mp3_url: Optional[str] = Field(None, description="播课音频URL")
|
||||||
|
generated_at: Optional[datetime] = Field(None, description="生成时间")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/courses/{course_id}/generate-broadcast", response_model=ResponseModel[GenerateBroadcastResponse])
|
||||||
|
async def generate_broadcast(
|
||||||
|
course_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_admin_or_manager)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
触发播课音频生成(立即返回,Coze工作流会直接写数据库)
|
||||||
|
|
||||||
|
权限:manager、admin
|
||||||
|
|
||||||
|
Args:
|
||||||
|
course_id: 课程ID
|
||||||
|
db: 数据库会话
|
||||||
|
current_user: 当前用户
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
启动提示信息
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: 课程不存在
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"请求生成播课",
|
||||||
|
extra={"course_id": course_id, "user_id": current_user.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 查询课程
|
||||||
|
result = await db.execute(
|
||||||
|
select(Course)
|
||||||
|
.where(Course.id == course_id)
|
||||||
|
.where(Course.is_deleted == False)
|
||||||
|
)
|
||||||
|
course = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not course:
|
||||||
|
logger.warning(f"课程不存在", extra={"course_id": course_id})
|
||||||
|
raise HTTPException(status_code=404, detail="课程不存在")
|
||||||
|
|
||||||
|
# 调用 Coze 工作流(不等待结果,工作流会直接写数据库)
|
||||||
|
try:
|
||||||
|
await broadcast_service.trigger_workflow(course_id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"播课生成工作流已触发",
|
||||||
|
extra={"course_id": course_id, "user_id": current_user.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="播课生成已启动",
|
||||||
|
data=GenerateBroadcastResponse(
|
||||||
|
message="播课生成工作流已启动,生成完成后将自动更新"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"触发播课生成失败",
|
||||||
|
extra={"course_id": course_id, "error": str(e)}
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=500, detail=f"触发播课生成失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/courses/{course_id}/broadcast", response_model=ResponseModel[BroadcastInfo])
|
||||||
|
async def get_broadcast_info(
|
||||||
|
course_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取播课信息
|
||||||
|
|
||||||
|
权限:所有登录用户
|
||||||
|
|
||||||
|
Args:
|
||||||
|
course_id: 课程ID
|
||||||
|
db: 数据库会话
|
||||||
|
current_user: 当前用户
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
播课信息
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: 课程不存在
|
||||||
|
"""
|
||||||
|
# 查询课程
|
||||||
|
result = await db.execute(
|
||||||
|
select(Course)
|
||||||
|
.where(Course.id == course_id)
|
||||||
|
.where(Course.is_deleted == False)
|
||||||
|
)
|
||||||
|
course = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not course:
|
||||||
|
raise HTTPException(status_code=404, detail="课程不存在")
|
||||||
|
|
||||||
|
# 构建播课信息
|
||||||
|
has_broadcast = bool(course.broadcast_audio_url)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data=BroadcastInfo(
|
||||||
|
has_broadcast=has_broadcast,
|
||||||
|
mp3_url=course.broadcast_audio_url if has_broadcast else None,
|
||||||
|
generated_at=course.broadcast_generated_at if has_broadcast else None
|
||||||
|
)
|
||||||
|
)
|
||||||
190
backend/app/api/v1/course_chat.py
Normal file
190
backend/app/api/v1/course_chat.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""
|
||||||
|
与课程对话 API
|
||||||
|
|
||||||
|
使用 Python 原生 AI 服务实现
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_db, get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.ai.course_chat_service import course_chat_service_v2
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CourseChatRequest(BaseModel):
|
||||||
|
"""课程对话请求"""
|
||||||
|
course_id: int = Field(..., description="课程ID")
|
||||||
|
query: str = Field(..., description="用户问题")
|
||||||
|
conversation_id: Optional[str] = Field(None, description="会话ID(续接对话时传入)")
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseModel(BaseModel):
|
||||||
|
"""通用响应模型"""
|
||||||
|
code: int = 200
|
||||||
|
message: str = "success"
|
||||||
|
data: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _chat_with_course(
|
||||||
|
request: CourseChatRequest,
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Python 原生实现的流式对话
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"用户 {current_user.username} 与课程 {request.course_id} 对话: "
|
||||||
|
f"{request.query[:50]}..."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate_stream():
|
||||||
|
"""生成 SSE 流"""
|
||||||
|
try:
|
||||||
|
async for event_type, data in course_chat_service_v2.chat_stream(
|
||||||
|
db=db,
|
||||||
|
course_id=request.course_id,
|
||||||
|
query=request.query,
|
||||||
|
user_id=current_user.id,
|
||||||
|
conversation_id=request.conversation_id
|
||||||
|
):
|
||||||
|
if event_type == "conversation_started":
|
||||||
|
yield f"data: {json.dumps({'event': 'conversation_started', 'conversation_id': data})}\n\n"
|
||||||
|
logger.info(f"会话已创建: {data}")
|
||||||
|
|
||||||
|
elif event_type == "chunk":
|
||||||
|
yield f"data: {json.dumps({'event': 'message_chunk', 'chunk': data})}\n\n"
|
||||||
|
|
||||||
|
elif event_type == "done":
|
||||||
|
yield f"data: {json.dumps({'event': 'message_end', 'message': data})}\n\n"
|
||||||
|
logger.info(f"对话完成,总长度: {len(data)}")
|
||||||
|
|
||||||
|
elif event_type == "error":
|
||||||
|
yield f"data: {json.dumps({'event': 'error', 'message': data})}\n\n"
|
||||||
|
logger.error(f"对话错误: {data}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"流式对话异常: {e}", exc_info=True)
|
||||||
|
yield f"data: {json.dumps({'event': 'error', 'message': str(e)})}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate_stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat")
|
||||||
|
async def chat_with_course(
|
||||||
|
request: CourseChatRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
与课程对话(流式响应)
|
||||||
|
|
||||||
|
使用 Python 原生 AI 服务实现,支持多轮对话。
|
||||||
|
"""
|
||||||
|
return await _chat_with_course(request, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/conversations")
|
||||||
|
async def get_conversations(
|
||||||
|
course_id: Optional[int] = None,
|
||||||
|
limit: int = 20,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取会话列表
|
||||||
|
|
||||||
|
返回当前用户的历史会话列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conversations = await course_chat_service_v2.get_conversations(
|
||||||
|
user_id=current_user.id,
|
||||||
|
course_id=course_id,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取会话列表成功",
|
||||||
|
data={
|
||||||
|
"conversations": conversations,
|
||||||
|
"total": len(conversations)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取会话列表失败: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取会话列表失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/messages")
|
||||||
|
async def get_messages(
|
||||||
|
conversation_id: str,
|
||||||
|
limit: int = 50,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取历史消息
|
||||||
|
|
||||||
|
返回指定会话的历史消息
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
messages = await course_chat_service_v2.get_messages(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取历史消息成功",
|
||||||
|
data={
|
||||||
|
"messages": messages,
|
||||||
|
"total": len(messages)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取历史消息失败: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取历史消息失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/engines")
|
||||||
|
async def list_chat_engines():
|
||||||
|
"""
|
||||||
|
获取可用的对话引擎列表
|
||||||
|
"""
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取对话引擎列表成功",
|
||||||
|
data={
|
||||||
|
"engines": [
|
||||||
|
{
|
||||||
|
"id": "native",
|
||||||
|
"name": "Python 原生实现",
|
||||||
|
"description": "使用本地 AI 服务(4sapi.com + OpenRouter),支持流式输出和多轮对话",
|
||||||
|
"default": True
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default_engine": "native"
|
||||||
|
}
|
||||||
|
)
|
||||||
786
backend/app/api/v1/courses.py
Normal file
786
backend/app/api/v1/courses.py
Normal file
@@ -0,0 +1,786 @@
|
|||||||
|
"""
|
||||||
|
课程管理API路由
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status, BackgroundTasks, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_db, get_current_user, require_admin, require_admin_or_manager, User
|
||||||
|
from app.core.exceptions import NotFoundError, BadRequestError
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
from app.services.system_log_service import system_log_service
|
||||||
|
from app.schemas.system_log import SystemLogCreate
|
||||||
|
from app.models.course import CourseStatus, CourseCategory
|
||||||
|
from app.schemas.base import ResponseModel, PaginationParams, PaginatedResponse
|
||||||
|
from app.schemas.course import (
|
||||||
|
CourseCreate,
|
||||||
|
CourseUpdate,
|
||||||
|
CourseInDB,
|
||||||
|
CourseList,
|
||||||
|
CourseMaterialCreate,
|
||||||
|
CourseMaterialInDB,
|
||||||
|
KnowledgePointCreate,
|
||||||
|
KnowledgePointUpdate,
|
||||||
|
KnowledgePointInDB,
|
||||||
|
GrowthPathCreate,
|
||||||
|
GrowthPathInDB,
|
||||||
|
CourseExamSettingsCreate,
|
||||||
|
CourseExamSettingsUpdate,
|
||||||
|
CourseExamSettingsInDB,
|
||||||
|
CoursePositionAssignment,
|
||||||
|
CoursePositionAssignmentInDB,
|
||||||
|
)
|
||||||
|
from app.services.course_service import (
|
||||||
|
course_service,
|
||||||
|
knowledge_point_service,
|
||||||
|
growth_path_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter(prefix="/courses", tags=["courses"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ResponseModel[PaginatedResponse[CourseInDB]])
|
||||||
|
async def get_courses(
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
status: Optional[CourseStatus] = Query(None, description="课程状态"),
|
||||||
|
category: Optional[CourseCategory] = Query(None, description="课程分类"),
|
||||||
|
is_featured: Optional[bool] = Query(None, description="是否推荐"),
|
||||||
|
keyword: Optional[str] = Query(None, description="搜索关键词"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取课程列表(支持分页和筛选)
|
||||||
|
|
||||||
|
- **page**: 页码
|
||||||
|
- **size**: 每页数量
|
||||||
|
- **status**: 课程状态筛选
|
||||||
|
- **category**: 课程分类筛选
|
||||||
|
- **is_featured**: 是否推荐筛选
|
||||||
|
- **keyword**: 关键词搜索(搜索名称和描述)
|
||||||
|
"""
|
||||||
|
page_params = PaginationParams(page=page, page_size=size)
|
||||||
|
filters = CourseList(
|
||||||
|
status=status, category=category, is_featured=is_featured, keyword=keyword
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await course_service.get_course_list(
|
||||||
|
db, page_params=page_params, filters=filters, user_id=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=result, message="获取课程列表成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"", response_model=ResponseModel[CourseInDB], status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def create_course(
|
||||||
|
course_in: CourseCreate,
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
创建课程(需要管理员权限)
|
||||||
|
|
||||||
|
- **name**: 课程名称
|
||||||
|
- **description**: 课程描述
|
||||||
|
- **category**: 课程分类
|
||||||
|
- **status**: 课程状态(默认为草稿)
|
||||||
|
- **cover_image**: 封面图片URL
|
||||||
|
- **duration_hours**: 课程时长(小时)
|
||||||
|
- **difficulty_level**: 难度等级(1-5)
|
||||||
|
- **tags**: 标签列表
|
||||||
|
- **is_featured**: 是否推荐
|
||||||
|
"""
|
||||||
|
course = await course_service.create_course(
|
||||||
|
db, course_in=course_in, created_by=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录课程创建日志
|
||||||
|
await system_log_service.create_log(
|
||||||
|
db,
|
||||||
|
SystemLogCreate(
|
||||||
|
level="INFO",
|
||||||
|
type="api",
|
||||||
|
message=f"创建课程: {course.name}",
|
||||||
|
user_id=current_user.id,
|
||||||
|
user=current_user.username,
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
path="/api/v1/courses",
|
||||||
|
method="POST",
|
||||||
|
user_agent=request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=course, message="创建课程成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{course_id}", response_model=ResponseModel[CourseInDB])
|
||||||
|
async def get_course(
|
||||||
|
course_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取课程详情
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
"""
|
||||||
|
course = await course_service.get_by_id(db, course_id)
|
||||||
|
if not course:
|
||||||
|
raise NotFoundError(f"课程ID {course_id} 不存在")
|
||||||
|
|
||||||
|
logger.info(f"查看课程详情 - course_id: {course_id}, user_id: {current_user.id}")
|
||||||
|
|
||||||
|
return ResponseModel(data=course, message="获取课程详情成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{course_id}", response_model=ResponseModel[CourseInDB])
|
||||||
|
async def update_course(
|
||||||
|
course_id: int,
|
||||||
|
course_in: CourseUpdate,
|
||||||
|
current_user: User = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
更新课程(需要管理员权限)
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
- **course_in**: 更新的课程数据(所有字段都是可选的)
|
||||||
|
"""
|
||||||
|
course = await course_service.update_course(
|
||||||
|
db, course_id=course_id, course_in=course_in, updated_by=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=course, message="更新课程成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{course_id}", response_model=ResponseModel[bool])
|
||||||
|
async def delete_course(
|
||||||
|
course_id: int,
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(require_admin_or_manager),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除课程(需要管理员权限)
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
|
||||||
|
说明:任意状态均可软删除(is_deleted=1),请谨慎操作
|
||||||
|
"""
|
||||||
|
# 先获取课程信息
|
||||||
|
course = await course_service.get_by_id(db, course_id)
|
||||||
|
course_name = course.name if course else f"ID:{course_id}"
|
||||||
|
|
||||||
|
success = await course_service.delete_course(
|
||||||
|
db, course_id=course_id, deleted_by=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录课程删除日志
|
||||||
|
if success:
|
||||||
|
await system_log_service.create_log(
|
||||||
|
db,
|
||||||
|
SystemLogCreate(
|
||||||
|
level="INFO",
|
||||||
|
type="api",
|
||||||
|
message=f"删除课程: {course_name}",
|
||||||
|
user_id=current_user.id,
|
||||||
|
user=current_user.username,
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
path=f"/api/v1/courses/{course_id}",
|
||||||
|
method="DELETE",
|
||||||
|
user_agent=request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=success, message="删除课程成功" if success else "删除课程失败")
|
||||||
|
|
||||||
|
|
||||||
|
# 课程资料相关API
|
||||||
|
@router.post(
|
||||||
|
"/{course_id}/materials",
|
||||||
|
response_model=ResponseModel[CourseMaterialInDB],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def add_course_material(
|
||||||
|
course_id: int,
|
||||||
|
material_in: CourseMaterialCreate,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
current_user: User = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
添加课程资料(需要管理员权限)
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
- **name**: 资料名称
|
||||||
|
- **description**: 资料描述
|
||||||
|
- **file_url**: 文件URL
|
||||||
|
- **file_type**: 文件类型(pdf, doc, docx, ppt, pptx, xls, xlsx, mp4, mp3, zip)
|
||||||
|
- **file_size**: 文件大小(字节)
|
||||||
|
|
||||||
|
添加资料后会自动触发知识点分析
|
||||||
|
"""
|
||||||
|
material = await course_service.add_course_material(
|
||||||
|
db, course_id=course_id, material_in=material_in, created_by=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取课程信息用于知识点分析
|
||||||
|
course = await course_service.get_by_id(db, course_id)
|
||||||
|
if course:
|
||||||
|
# 异步触发知识点分析
|
||||||
|
from app.services.ai.knowledge_analysis_v2 import knowledge_analysis_service_v2
|
||||||
|
background_tasks.add_task(
|
||||||
|
_trigger_knowledge_analysis,
|
||||||
|
db,
|
||||||
|
course_id,
|
||||||
|
material.id,
|
||||||
|
material.file_url,
|
||||||
|
course.name,
|
||||||
|
current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"资料添加成功,已触发知识点分析 - course_id: {course_id}, material_id: {material.id}, user_id: {current_user.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=material, message="添加课程资料成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{course_id}/materials",
|
||||||
|
response_model=ResponseModel[List[CourseMaterialInDB]],
|
||||||
|
)
|
||||||
|
async def list_course_materials(
|
||||||
|
course_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取课程资料列表
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
"""
|
||||||
|
materials = await course_service.get_course_materials(db, course_id=course_id)
|
||||||
|
return ResponseModel(data=materials, message="获取课程资料列表成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{course_id}/materials/{material_id}",
|
||||||
|
response_model=ResponseModel[bool],
|
||||||
|
)
|
||||||
|
async def delete_course_material(
|
||||||
|
course_id: int,
|
||||||
|
material_id: int,
|
||||||
|
current_user: User = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除课程资料(需要管理员权限)
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
- **material_id**: 资料ID
|
||||||
|
"""
|
||||||
|
success = await course_service.delete_course_material(
|
||||||
|
db, course_id=course_id, material_id=material_id, deleted_by=current_user.id
|
||||||
|
)
|
||||||
|
return ResponseModel(data=success, message="删除课程资料成功" if success else "删除课程资料失败")
|
||||||
|
|
||||||
|
|
||||||
|
# 知识点相关API
|
||||||
|
@router.get(
|
||||||
|
"/{course_id}/knowledge-points",
|
||||||
|
response_model=ResponseModel[List[KnowledgePointInDB]],
|
||||||
|
)
|
||||||
|
async def get_course_knowledge_points(
|
||||||
|
course_id: int,
|
||||||
|
material_id: Optional[int] = Query(None, description="资料ID"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取课程的知识点列表
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
- **material_id**: 资料ID(可选,用于筛选特定资料的知识点)
|
||||||
|
"""
|
||||||
|
# 先检查课程是否存在
|
||||||
|
course = await course_service.get_by_id(db, course_id)
|
||||||
|
if not course:
|
||||||
|
raise NotFoundError(f"课程ID {course_id} 不存在")
|
||||||
|
|
||||||
|
knowledge_points = await knowledge_point_service.get_knowledge_points_by_course(
|
||||||
|
db, course_id=course_id, material_id=material_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=knowledge_points, message="获取知识点列表成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{course_id}/knowledge-points",
|
||||||
|
response_model=ResponseModel[KnowledgePointInDB],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_knowledge_point(
|
||||||
|
course_id: int,
|
||||||
|
point_in: KnowledgePointCreate,
|
||||||
|
current_user: User = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
创建知识点(需要管理员权限)
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
- **name**: 知识点名称
|
||||||
|
- **description**: 知识点描述
|
||||||
|
- **parent_id**: 父知识点ID
|
||||||
|
- **weight**: 权重(0-10)
|
||||||
|
- **is_required**: 是否必修
|
||||||
|
- **estimated_hours**: 预计学习时间(小时)
|
||||||
|
"""
|
||||||
|
knowledge_point = await knowledge_point_service.create_knowledge_point(
|
||||||
|
db, course_id=course_id, point_in=point_in, created_by=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=knowledge_point, message="创建知识点成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/knowledge-points/{point_id}", response_model=ResponseModel[KnowledgePointInDB]
|
||||||
|
)
|
||||||
|
async def update_knowledge_point(
|
||||||
|
point_id: int,
|
||||||
|
point_in: KnowledgePointUpdate,
|
||||||
|
current_user: User = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
更新知识点(需要管理员权限)
|
||||||
|
|
||||||
|
- **point_id**: 知识点ID
|
||||||
|
- **point_in**: 更新的知识点数据(所有字段都是可选的)
|
||||||
|
"""
|
||||||
|
knowledge_point = await knowledge_point_service.update_knowledge_point(
|
||||||
|
db, point_id=point_id, point_in=point_in, updated_by=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=knowledge_point, message="更新知识点成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/knowledge-points/{point_id}", response_model=ResponseModel[bool])
|
||||||
|
async def delete_knowledge_point(
|
||||||
|
point_id: int,
|
||||||
|
current_user: User = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除知识点(需要管理员权限)
|
||||||
|
|
||||||
|
- **point_id**: 知识点ID
|
||||||
|
"""
|
||||||
|
success = await knowledge_point_service.delete(
|
||||||
|
db, id=point_id, soft=True, deleted_by=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.warning("删除知识点", knowledge_point_id=point_id, deleted_by=current_user.id)
|
||||||
|
|
||||||
|
return ResponseModel(data=success, message="删除知识点成功" if success else "删除知识点失败")
|
||||||
|
|
||||||
|
|
||||||
|
# 资料知识点关联API
|
||||||
|
@router.get(
|
||||||
|
"/materials/{material_id}/knowledge-points",
|
||||||
|
response_model=ResponseModel[List[KnowledgePointInDB]],
|
||||||
|
)
|
||||||
|
async def get_material_knowledge_points(
|
||||||
|
material_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取资料关联的知识点列表
|
||||||
|
"""
|
||||||
|
knowledge_points = await course_service.get_material_knowledge_points(
|
||||||
|
db, material_id=material_id
|
||||||
|
)
|
||||||
|
return ResponseModel(data=knowledge_points, message="获取知识点列表成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/materials/{material_id}/knowledge-points",
|
||||||
|
response_model=ResponseModel[List[KnowledgePointInDB]],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def add_material_knowledge_points(
|
||||||
|
material_id: int,
|
||||||
|
knowledge_point_ids: List[int],
|
||||||
|
current_user: User = Depends(require_admin_or_manager),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
为资料添加知识点关联(需要管理员或经理权限)
|
||||||
|
"""
|
||||||
|
knowledge_points = await course_service.add_material_knowledge_points(
|
||||||
|
db, material_id=material_id, knowledge_point_ids=knowledge_point_ids
|
||||||
|
)
|
||||||
|
return ResponseModel(data=knowledge_points, message="添加知识点成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/materials/{material_id}/knowledge-points/{knowledge_point_id}",
|
||||||
|
response_model=ResponseModel[bool],
|
||||||
|
)
|
||||||
|
async def remove_material_knowledge_point(
|
||||||
|
material_id: int,
|
||||||
|
knowledge_point_id: int,
|
||||||
|
current_user: User = Depends(require_admin_or_manager),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
移除资料的知识点关联(需要管理员或经理权限)
|
||||||
|
"""
|
||||||
|
success = await course_service.remove_material_knowledge_point(
|
||||||
|
db, material_id=material_id, knowledge_point_id=knowledge_point_id
|
||||||
|
)
|
||||||
|
return ResponseModel(data=success, message="移除知识点成功" if success else "移除失败")
|
||||||
|
|
||||||
|
|
||||||
|
# 成长路径相关API
|
||||||
|
@router.post(
|
||||||
|
"/growth-paths",
|
||||||
|
response_model=ResponseModel[GrowthPathInDB],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_growth_path(
|
||||||
|
path_in: GrowthPathCreate,
|
||||||
|
current_user: User = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
创建成长路径(需要管理员权限)
|
||||||
|
|
||||||
|
- **name**: 路径名称
|
||||||
|
- **description**: 路径描述
|
||||||
|
- **target_role**: 目标角色
|
||||||
|
- **courses**: 课程列表(包含course_id、order、is_required)
|
||||||
|
- **estimated_duration_days**: 预计完成天数
|
||||||
|
- **is_active**: 是否启用
|
||||||
|
"""
|
||||||
|
growth_path = await growth_path_service.create_growth_path(
|
||||||
|
db, path_in=path_in, created_by=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=growth_path, message="创建成长路径成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/growth-paths", response_model=ResponseModel[PaginatedResponse[GrowthPathInDB]]
|
||||||
|
)
|
||||||
|
async def get_growth_paths(
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
is_active: Optional[bool] = Query(None, description="是否启用"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取成长路径列表
|
||||||
|
|
||||||
|
- **page**: 页码
|
||||||
|
- **size**: 每页数量
|
||||||
|
- **is_active**: 是否启用筛选
|
||||||
|
"""
|
||||||
|
page_params = PaginationParams(page=page, page_size=size)
|
||||||
|
|
||||||
|
filters = []
|
||||||
|
if is_active is not None:
|
||||||
|
from app.models.course import GrowthPath
|
||||||
|
|
||||||
|
filters.append(GrowthPath.is_active == is_active)
|
||||||
|
|
||||||
|
result = await growth_path_service.get_page(
|
||||||
|
db, page_params=page_params, filters=filters
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=result, message="获取成长路径列表成功")
|
||||||
|
|
||||||
|
|
||||||
|
# 课程考试设置相关API
|
||||||
|
@router.get(
|
||||||
|
"/{course_id}/exam-settings",
|
||||||
|
response_model=ResponseModel[Optional[CourseExamSettingsInDB]],
|
||||||
|
)
|
||||||
|
async def get_course_exam_settings(
|
||||||
|
course_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取课程的考试设置
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
"""
|
||||||
|
# 检查课程是否存在
|
||||||
|
course = await course_service.get_by_id(db, course_id)
|
||||||
|
if not course:
|
||||||
|
raise NotFoundError(f"课程ID {course_id} 不存在")
|
||||||
|
|
||||||
|
# 获取考试设置
|
||||||
|
from app.services.course_exam_service import course_exam_service
|
||||||
|
settings = await course_exam_service.get_by_course_id(db, course_id)
|
||||||
|
|
||||||
|
# 添加调试日志
|
||||||
|
if settings:
|
||||||
|
logger.info(
|
||||||
|
f"📊 获取考试设置成功 - course_id: {course_id}, "
|
||||||
|
f"单选: {settings.single_choice_count}, 多选: {settings.multiple_choice_count}, "
|
||||||
|
f"判断: {settings.true_false_count}, 填空: {settings.fill_blank_count}, "
|
||||||
|
f"问答: {settings.essay_count}, 难度: {settings.difficulty_level}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ 课程 {course_id} 没有配置考试设置,将使用默认值")
|
||||||
|
|
||||||
|
return ResponseModel(data=settings, message="获取考试设置成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{course_id}/exam-settings",
|
||||||
|
response_model=ResponseModel[CourseExamSettingsInDB],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_course_exam_settings(
|
||||||
|
course_id: int,
|
||||||
|
settings_in: CourseExamSettingsCreate,
|
||||||
|
current_user: User = Depends(require_admin_or_manager),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
创建或更新课程的考试设置(需要管理员权限)
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
- **settings_in**: 考试设置数据
|
||||||
|
"""
|
||||||
|
# 检查课程是否存在
|
||||||
|
course = await course_service.get_by_id(db, course_id)
|
||||||
|
if not course:
|
||||||
|
raise NotFoundError(f"课程ID {course_id} 不存在")
|
||||||
|
|
||||||
|
# 创建或更新考试设置
|
||||||
|
from app.services.course_exam_service import course_exam_service
|
||||||
|
settings = await course_exam_service.create_or_update(
|
||||||
|
db, course_id=course_id, settings_in=settings_in, user_id=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=settings, message="保存考试设置成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{course_id}/exam-settings",
|
||||||
|
response_model=ResponseModel[CourseExamSettingsInDB],
|
||||||
|
)
|
||||||
|
async def update_course_exam_settings(
|
||||||
|
course_id: int,
|
||||||
|
settings_in: CourseExamSettingsUpdate,
|
||||||
|
current_user: User = Depends(require_admin_or_manager),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
更新课程的考试设置(需要管理员权限)
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
- **settings_in**: 更新的考试设置数据
|
||||||
|
"""
|
||||||
|
# 检查课程是否存在
|
||||||
|
course = await course_service.get_by_id(db, course_id)
|
||||||
|
if not course:
|
||||||
|
raise NotFoundError(f"课程ID {course_id} 不存在")
|
||||||
|
|
||||||
|
# 更新考试设置
|
||||||
|
from app.services.course_exam_service import course_exam_service
|
||||||
|
settings = await course_exam_service.update(
|
||||||
|
db, course_id=course_id, settings_in=settings_in, user_id=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=settings, message="更新考试设置成功")
|
||||||
|
|
||||||
|
|
||||||
|
# 课程岗位分配相关API
|
||||||
|
@router.get(
|
||||||
|
"/{course_id}/positions",
|
||||||
|
response_model=ResponseModel[List[CoursePositionAssignmentInDB]],
|
||||||
|
)
|
||||||
|
async def get_course_positions(
|
||||||
|
course_id: int,
|
||||||
|
course_type: Optional[str] = Query(None, pattern="^(required|optional)$", description="课程类型筛选"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取课程的岗位分配列表
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
- **course_type**: 课程类型筛选(required必修/optional选修)
|
||||||
|
"""
|
||||||
|
# 检查课程是否存在
|
||||||
|
course = await course_service.get_by_id(db, course_id)
|
||||||
|
if not course:
|
||||||
|
raise NotFoundError(f"课程ID {course_id} 不存在")
|
||||||
|
|
||||||
|
# 获取岗位分配列表
|
||||||
|
from app.services.course_position_service import course_position_service
|
||||||
|
assignments = await course_position_service.get_course_positions(
|
||||||
|
db, course_id=course_id, course_type=course_type
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=assignments, message="获取岗位分配列表成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{course_id}/positions",
|
||||||
|
response_model=ResponseModel[List[CoursePositionAssignmentInDB]],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def assign_course_positions(
|
||||||
|
course_id: int,
|
||||||
|
assignments: List[CoursePositionAssignment],
|
||||||
|
current_user: User = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
批量分配课程到岗位(需要管理员权限)
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
- **assignments**: 岗位分配列表
|
||||||
|
"""
|
||||||
|
# 检查课程是否存在
|
||||||
|
course = await course_service.get_by_id(db, course_id)
|
||||||
|
if not course:
|
||||||
|
raise NotFoundError(f"课程ID {course_id} 不存在")
|
||||||
|
|
||||||
|
# 批量分配岗位
|
||||||
|
from app.services.course_position_service import course_position_service
|
||||||
|
result = await course_position_service.batch_assign_positions(
|
||||||
|
db, course_id=course_id, assignments=assignments, user_id=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# 发送课程分配通知给相关岗位的学员
|
||||||
|
try:
|
||||||
|
from app.models.position_member import PositionMember
|
||||||
|
from app.services.notification_service import notification_service
|
||||||
|
from app.schemas.notification import NotificationBatchCreate, NotificationType
|
||||||
|
|
||||||
|
# 获取所有分配岗位的学员ID
|
||||||
|
position_ids = [a.position_id for a in assignments]
|
||||||
|
if position_ids:
|
||||||
|
member_result = await db.execute(
|
||||||
|
select(PositionMember.user_id).where(
|
||||||
|
PositionMember.position_id.in_(position_ids),
|
||||||
|
PositionMember.is_deleted == False
|
||||||
|
).distinct()
|
||||||
|
)
|
||||||
|
user_ids = [row[0] for row in member_result.fetchall()]
|
||||||
|
|
||||||
|
if user_ids:
|
||||||
|
notification_batch = NotificationBatchCreate(
|
||||||
|
user_ids=user_ids,
|
||||||
|
title="新课程通知",
|
||||||
|
content=f"您所在岗位有新课程「{course.name}」已分配,请及时学习。",
|
||||||
|
type=NotificationType.COURSE_ASSIGN,
|
||||||
|
related_id=course_id,
|
||||||
|
related_type="course",
|
||||||
|
sender_id=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
await notification_service.batch_create_notifications(
|
||||||
|
db=db,
|
||||||
|
batch_in=notification_batch
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# 通知发送失败不影响课程分配结果
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).error(f"发送课程分配通知失败: {str(e)}")
|
||||||
|
|
||||||
|
return ResponseModel(data=result, message="岗位分配成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{course_id}/positions/{position_id}",
|
||||||
|
response_model=ResponseModel[bool],
|
||||||
|
)
|
||||||
|
async def remove_course_position(
|
||||||
|
course_id: int,
|
||||||
|
position_id: int,
|
||||||
|
current_user: User = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
移除课程的岗位分配(需要管理员权限)
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
- **position_id**: 岗位ID
|
||||||
|
"""
|
||||||
|
# 检查课程是否存在
|
||||||
|
course = await course_service.get_by_id(db, course_id)
|
||||||
|
if not course:
|
||||||
|
raise NotFoundError(f"课程ID {course_id} 不存在")
|
||||||
|
|
||||||
|
# 移除岗位分配
|
||||||
|
from app.services.course_position_service import course_position_service
|
||||||
|
success = await course_position_service.remove_position_assignment(
|
||||||
|
db, course_id=course_id, position_id=position_id, user_id=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=success, message="移除岗位分配成功" if success else "移除岗位分配失败")
|
||||||
|
|
||||||
|
|
||||||
|
async def _trigger_knowledge_analysis(
|
||||||
|
db: AsyncSession,
|
||||||
|
course_id: int,
|
||||||
|
material_id: int,
|
||||||
|
file_url: str,
|
||||||
|
course_title: str,
|
||||||
|
user_id: int
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
后台触发知识点分析任务
|
||||||
|
|
||||||
|
注意:此函数在后台任务中执行,异常不会影响资料添加的成功响应
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.services.ai.knowledge_analysis_v2 import knowledge_analysis_service_v2
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"后台知识点分析开始 - course_id: {course_id}, material_id: {material_id}, file_url: {file_url}, user_id: {user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await knowledge_analysis_service_v2.analyze_course_material(
|
||||||
|
db=db,
|
||||||
|
course_id=course_id,
|
||||||
|
material_id=material_id,
|
||||||
|
file_url=file_url,
|
||||||
|
course_title=course_title,
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"后台知识点分析完成 - course_id: {course_id}, material_id: {material_id}, knowledge_points_count: {result.get('knowledge_points_count', 0)}, user_id: {user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
# 文件不存在时记录警告,但不记录完整堆栈
|
||||||
|
logger.warning(
|
||||||
|
f"后台知识点分析失败(文件不存在) - course_id: {course_id}, material_id: {material_id}, "
|
||||||
|
f"file_url: {file_url}, error: {str(e)}, user_id: {user_id}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# 其他异常记录详细信息
|
||||||
|
logger.error(
|
||||||
|
f"后台知识点分析失败 - course_id: {course_id}, material_id: {material_id}, error: {str(e)}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
275
backend/app/api/v1/coze_gateway.py
Normal file
275
backend/app/api/v1/coze_gateway.py
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
"""
|
||||||
|
Coze 网关 API 路由
|
||||||
|
提供课程对话和陪练功能的统一接口
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
|
from app.services.ai.coze import (
|
||||||
|
get_coze_service,
|
||||||
|
CreateSessionRequest,
|
||||||
|
SendMessageRequest,
|
||||||
|
EndSessionRequest,
|
||||||
|
SessionType,
|
||||||
|
CozeException,
|
||||||
|
StreamEventType,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["coze-gateway"])
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: 依赖注入获取当前用户
|
||||||
|
async def get_current_user():
|
||||||
|
"""获取当前登录用户(临时实现)"""
|
||||||
|
# 实际应该从 Auth 模块获取
|
||||||
|
return {"user_id": "test-user-123", "username": "test_user"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/course-chat/sessions")
|
||||||
|
async def create_course_chat_session(course_id: str, user=Depends(get_current_user)):
|
||||||
|
"""
|
||||||
|
创建课程对话会话
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
service = get_coze_service()
|
||||||
|
request = CreateSessionRequest(
|
||||||
|
session_type=SessionType.COURSE_CHAT,
|
||||||
|
user_id=user["user_id"],
|
||||||
|
course_id=course_id,
|
||||||
|
metadata={"username": user["username"], "course_id": course_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await service.create_session(request)
|
||||||
|
|
||||||
|
return {"code": 200, "message": "success", "data": response.dict()}
|
||||||
|
|
||||||
|
except CozeException as e:
|
||||||
|
logger.error(f"创建课程对话会话失败: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.status_code or 500,
|
||||||
|
detail={"code": e.code, "message": e.message, "details": e.details},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"未知错误: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/training/sessions")
|
||||||
|
async def create_training_session(
|
||||||
|
training_topic: str = None, user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
创建陪练会话
|
||||||
|
|
||||||
|
- **training_topic**: 陪练主题(可选)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
service = get_coze_service()
|
||||||
|
request = CreateSessionRequest(
|
||||||
|
session_type=SessionType.TRAINING,
|
||||||
|
user_id=user["user_id"],
|
||||||
|
training_topic=training_topic,
|
||||||
|
metadata={"username": user["username"], "training_topic": training_topic},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await service.create_session(request)
|
||||||
|
|
||||||
|
return {"code": 200, "message": "success", "data": response.dict()}
|
||||||
|
|
||||||
|
except CozeException as e:
|
||||||
|
logger.error(f"创建陪练会话失败: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.status_code or 500,
|
||||||
|
detail={"code": e.code, "message": e.message, "details": e.details},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"未知错误: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/training/sessions/{session_id}/end")
|
||||||
|
async def end_training_session(
|
||||||
|
session_id: str, request: EndSessionRequest, user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
结束陪练会话
|
||||||
|
|
||||||
|
- **session_id**: 会话ID
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
service = get_coze_service()
|
||||||
|
response = await service.end_session(session_id, request)
|
||||||
|
|
||||||
|
return {"code": 200, "message": "success", "data": response.dict()}
|
||||||
|
|
||||||
|
except CozeException as e:
|
||||||
|
logger.error(f"结束会话失败: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.status_code or 500,
|
||||||
|
detail={"code": e.code, "message": e.message, "details": e.details},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"未知错误: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat/messages")
|
||||||
|
async def send_message(request: SendMessageRequest, user=Depends(get_current_user)):
|
||||||
|
"""
|
||||||
|
发送消息(支持流式响应)
|
||||||
|
|
||||||
|
- **session_id**: 会话ID
|
||||||
|
- **content**: 消息内容
|
||||||
|
- **stream**: 是否流式响应(默认True)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
service = get_coze_service()
|
||||||
|
|
||||||
|
if request.stream:
|
||||||
|
# 流式响应
|
||||||
|
async def event_generator():
|
||||||
|
async for event in service.send_message(request):
|
||||||
|
# 转换为 SSE 格式
|
||||||
|
if event.event == StreamEventType.MESSAGE_DELTA:
|
||||||
|
yield {
|
||||||
|
"event": "message",
|
||||||
|
"data": {
|
||||||
|
"type": "delta",
|
||||||
|
"content": event.content,
|
||||||
|
"content_type": event.content_type.value,
|
||||||
|
"message_id": event.message_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
elif event.event == StreamEventType.MESSAGE_COMPLETED:
|
||||||
|
yield {
|
||||||
|
"event": "message",
|
||||||
|
"data": {
|
||||||
|
"type": "completed",
|
||||||
|
"content": event.content,
|
||||||
|
"content_type": event.content_type.value,
|
||||||
|
"message_id": event.message_id,
|
||||||
|
"usage": event.data.get("usage", {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
elif event.event == StreamEventType.ERROR:
|
||||||
|
yield {"event": "error", "data": {"error": event.error}}
|
||||||
|
elif event.event == StreamEventType.DONE:
|
||||||
|
yield {
|
||||||
|
"event": "done",
|
||||||
|
"data": {"session_id": event.data.get("session_id")},
|
||||||
|
}
|
||||||
|
|
||||||
|
return EventSourceResponse(event_generator())
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 非流式响应(收集完整响应)
|
||||||
|
full_content = ""
|
||||||
|
content_type = None
|
||||||
|
message_id = None
|
||||||
|
|
||||||
|
async for event in service.send_message(request):
|
||||||
|
if event.event == StreamEventType.MESSAGE_COMPLETED:
|
||||||
|
full_content = event.content
|
||||||
|
content_type = event.content_type
|
||||||
|
message_id = event.message_id
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"message_id": message_id,
|
||||||
|
"content": full_content,
|
||||||
|
"content_type": content_type.value if content_type else "text",
|
||||||
|
"role": "assistant",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
except CozeException as e:
|
||||||
|
logger.error(f"发送消息失败: {e}")
|
||||||
|
if request.stream:
|
||||||
|
# 流式响应的错误处理
|
||||||
|
async def error_generator():
|
||||||
|
yield {
|
||||||
|
"event": "error",
|
||||||
|
"data": {
|
||||||
|
"code": e.code,
|
||||||
|
"message": e.message,
|
||||||
|
"details": e.details,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return EventSourceResponse(error_generator())
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.status_code or 500,
|
||||||
|
detail={"code": e.code, "message": e.message, "details": e.details},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"未知错误: {e}", exc_info=True)
|
||||||
|
if request.stream:
|
||||||
|
|
||||||
|
async def error_generator():
|
||||||
|
yield {
|
||||||
|
"event": "error",
|
||||||
|
"data": {"code": "INTERNAL_ERROR", "message": "服务器内部错误"},
|
||||||
|
}
|
||||||
|
|
||||||
|
return EventSourceResponse(error_generator())
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}/messages")
|
||||||
|
async def get_session_messages(
|
||||||
|
session_id: str, limit: int = 50, offset: int = 0, user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取会话消息历史
|
||||||
|
|
||||||
|
- **session_id**: 会话ID
|
||||||
|
- **limit**: 返回消息数量限制
|
||||||
|
- **offset**: 偏移量
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
service = get_coze_service()
|
||||||
|
messages = await service.get_session_messages(session_id, limit, offset)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"messages": [msg.dict() for msg in messages],
|
||||||
|
"total": len(messages),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取消息历史失败: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"},
|
||||||
|
)
|
||||||
236
backend/app/api/v1/endpoints/employee_sync.py
Normal file
236
backend/app/api/v1/endpoints/employee_sync.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""
|
||||||
|
员工同步API接口
|
||||||
|
提供从钉钉员工表同步员工数据的功能
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
from app.core.deps import get_current_user, get_db
|
||||||
|
from app.services.employee_sync_service import EmployeeSyncService
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync", summary="执行员工同步")
|
||||||
|
async def sync_employees(
|
||||||
|
*,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
从钉钉员工表同步在职员工数据到考培练系统
|
||||||
|
|
||||||
|
权限要求: 仅管理员可执行
|
||||||
|
|
||||||
|
同步内容:
|
||||||
|
- 创建用户账号(用户名=手机号,初始密码=123456)
|
||||||
|
- 创建部门团队
|
||||||
|
- 创建岗位并关联用户
|
||||||
|
- 设置领导为团队负责人
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
同步结果统计
|
||||||
|
"""
|
||||||
|
# 权限检查:仅管理员可执行
|
||||||
|
if current_user.role != 'admin':
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="只有管理员可以执行员工同步"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"管理员 {current_user.username} 开始执行员工同步")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with EmployeeSyncService(db) as sync_service:
|
||||||
|
stats = await sync_service.sync_employees()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "员工同步完成",
|
||||||
|
"data": stats
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"员工同步失败: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"员工同步失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/preview", summary="预览待同步员工数据")
|
||||||
|
async def preview_sync_data(
|
||||||
|
*,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
预览待同步的员工数据(不执行实际同步)
|
||||||
|
|
||||||
|
权限要求: 仅管理员可查看
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
预览数据,包括员工列表、部门列表、岗位列表等
|
||||||
|
"""
|
||||||
|
# 权限检查:仅管理员可查看
|
||||||
|
if current_user.role != 'admin':
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="只有管理员可以预览员工数据"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"管理员 {current_user.username} 预览员工同步数据")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with EmployeeSyncService(db) as sync_service:
|
||||||
|
preview_data = await sync_service.preview_sync_data()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "预览数据获取成功",
|
||||||
|
"data": preview_data
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"预览数据获取失败: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"预览数据获取失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/incremental-sync", summary="增量同步员工")
|
||||||
|
async def incremental_sync_employees(
|
||||||
|
*,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
增量同步钉钉员工数据
|
||||||
|
|
||||||
|
功能说明:
|
||||||
|
- 新增:钉钉有但系统没有的员工
|
||||||
|
- 删除:系统有但钉钉没有的员工(物理删除)
|
||||||
|
- 跳过:两边都存在的员工(不修改任何信息)
|
||||||
|
|
||||||
|
权限要求: 管理员(admin 或 manager)可执行
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
同步结果统计
|
||||||
|
"""
|
||||||
|
# 权限检查:管理员或经理可执行
|
||||||
|
if current_user.role not in ['admin', 'manager']:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="只有管理员可以执行员工同步"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"用户 {current_user.username} ({current_user.role}) 开始执行增量员工同步")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with EmployeeSyncService(db) as sync_service:
|
||||||
|
stats = await sync_service.incremental_sync_employees()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "增量同步完成",
|
||||||
|
"data": {
|
||||||
|
"added_count": stats['added_count'],
|
||||||
|
"deleted_count": stats['deleted_count'],
|
||||||
|
"skipped_count": stats['skipped_count'],
|
||||||
|
"added_users": stats['added_users'],
|
||||||
|
"deleted_users": stats['deleted_users'],
|
||||||
|
"errors": stats['errors'],
|
||||||
|
"duration": stats['duration']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"增量同步失败: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"增量同步失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", summary="查询同步状态")
|
||||||
|
async def get_sync_status(
|
||||||
|
*,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
查询当前系统的用户、团队、岗位统计信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
统计信息
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from app.models.user import User, Team
|
||||||
|
from app.models.position import Position
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 统计用户数量
|
||||||
|
user_count_stmt = select(func.count(User.id)).where(User.is_deleted == False)
|
||||||
|
user_result = await db.execute(user_count_stmt)
|
||||||
|
total_users = user_result.scalar()
|
||||||
|
|
||||||
|
# 统计各角色用户数量
|
||||||
|
admin_count_stmt = select(func.count(User.id)).where(
|
||||||
|
User.is_deleted == False,
|
||||||
|
User.role == 'admin'
|
||||||
|
)
|
||||||
|
admin_result = await db.execute(admin_count_stmt)
|
||||||
|
admin_count = admin_result.scalar()
|
||||||
|
|
||||||
|
manager_count_stmt = select(func.count(User.id)).where(
|
||||||
|
User.is_deleted == False,
|
||||||
|
User.role == 'manager'
|
||||||
|
)
|
||||||
|
manager_result = await db.execute(manager_count_stmt)
|
||||||
|
manager_count = manager_result.scalar()
|
||||||
|
|
||||||
|
trainee_count_stmt = select(func.count(User.id)).where(
|
||||||
|
User.is_deleted == False,
|
||||||
|
User.role == 'trainee'
|
||||||
|
)
|
||||||
|
trainee_result = await db.execute(trainee_count_stmt)
|
||||||
|
trainee_count = trainee_result.scalar()
|
||||||
|
|
||||||
|
# 统计团队数量
|
||||||
|
team_count_stmt = select(func.count(Team.id)).where(Team.is_deleted == False)
|
||||||
|
team_result = await db.execute(team_count_stmt)
|
||||||
|
total_teams = team_result.scalar()
|
||||||
|
|
||||||
|
# 统计岗位数量
|
||||||
|
position_count_stmt = select(func.count(Position.id)).where(Position.is_deleted == False)
|
||||||
|
position_result = await db.execute(position_count_stmt)
|
||||||
|
total_positions = position_result.scalar()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"users": {
|
||||||
|
"total": total_users,
|
||||||
|
"admin": admin_count,
|
||||||
|
"manager": manager_count,
|
||||||
|
"trainee": trainee_count
|
||||||
|
},
|
||||||
|
"teams": total_teams,
|
||||||
|
"positions": total_positions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"查询统计信息失败: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"查询统计信息失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
761
backend/app/api/v1/exam.py
Normal file
761
backend/app/api/v1/exam.py
Normal file
@@ -0,0 +1,761 @@
|
|||||||
|
"""
|
||||||
|
考试相关API路由
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException, status, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.deps import get_db, get_current_user
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.exam import Exam
|
||||||
|
from app.models.exam_mistake import ExamMistake
|
||||||
|
from app.models.position_member import PositionMember
|
||||||
|
from app.models.position_course import PositionCourse
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
from app.schemas.exam import (
|
||||||
|
StartExamRequest,
|
||||||
|
StartExamResponse,
|
||||||
|
SubmitExamRequest,
|
||||||
|
SubmitExamResponse,
|
||||||
|
ExamDetailResponse,
|
||||||
|
ExamRecordResponse,
|
||||||
|
GenerateExamRequest,
|
||||||
|
GenerateExamResponse,
|
||||||
|
JudgeAnswerRequest,
|
||||||
|
JudgeAnswerResponse,
|
||||||
|
RecordMistakeRequest,
|
||||||
|
RecordMistakeResponse,
|
||||||
|
GetMistakesResponse,
|
||||||
|
MistakeRecordItem,
|
||||||
|
# 新增的Schema
|
||||||
|
ExamReportResponse,
|
||||||
|
MistakeListResponse,
|
||||||
|
MistakesStatisticsResponse,
|
||||||
|
UpdateRoundScoreRequest,
|
||||||
|
)
|
||||||
|
from app.services.exam_report_service import ExamReportService, MistakeService
|
||||||
|
from app.services.course_statistics_service import course_statistics_service
|
||||||
|
from app.services.system_log_service import system_log_service
|
||||||
|
from app.schemas.system_log import SystemLogCreate
|
||||||
|
|
||||||
|
# V2 原生服务:Python 实现
|
||||||
|
from app.services.ai import exam_generator_service, ExamGeneratorConfig
|
||||||
|
from app.services.ai.answer_judge_service import answer_judge_service
|
||||||
|
from app.core.exceptions import ExternalServiceError
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/exams", tags=["考试"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start", response_model=ResponseModel[StartExamResponse])
|
||||||
|
async def start_exam(
|
||||||
|
request: StartExamRequest,
|
||||||
|
http_request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""开始考试"""
|
||||||
|
exam = await ExamService.start_exam(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
course_id=request.course_id,
|
||||||
|
question_count=request.count,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 异步更新课程学员数统计
|
||||||
|
try:
|
||||||
|
await course_statistics_service.update_course_student_count(db, request.course_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"更新课程学员数失败: {str(e)}")
|
||||||
|
# 不影响主流程,只记录警告
|
||||||
|
|
||||||
|
# 记录考试开始日志
|
||||||
|
await system_log_service.create_log(
|
||||||
|
db,
|
||||||
|
SystemLogCreate(
|
||||||
|
level="INFO",
|
||||||
|
type="api",
|
||||||
|
message=f"用户 {current_user.username} 开始考试(课程ID: {request.course_id})",
|
||||||
|
user_id=current_user.id,
|
||||||
|
user=current_user.username,
|
||||||
|
ip=http_request.client.host if http_request.client else None,
|
||||||
|
path="/api/v1/exams/start",
|
||||||
|
method="POST",
|
||||||
|
user_agent=http_request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(code=200, data=StartExamResponse(exam_id=exam.id), message="考试开始")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/submit", response_model=ResponseModel[SubmitExamResponse])
|
||||||
|
async def submit_exam(
|
||||||
|
request: SubmitExamRequest,
|
||||||
|
http_request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""提交考试答案"""
|
||||||
|
result = await ExamService.submit_exam(
|
||||||
|
db=db, user_id=current_user.id, exam_id=request.exam_id, answers=request.answers
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取考试记录以获取course_id
|
||||||
|
exam_stmt = select(Exam).where(Exam.id == request.exam_id)
|
||||||
|
exam_result = await db.execute(exam_stmt)
|
||||||
|
exam = exam_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 异步更新课程学员数统计
|
||||||
|
if exam and exam.course_id:
|
||||||
|
try:
|
||||||
|
await course_statistics_service.update_course_student_count(db, exam.course_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"更新课程学员数失败: {str(e)}")
|
||||||
|
# 不影响主流程,只记录警告
|
||||||
|
|
||||||
|
# 记录考试提交日志
|
||||||
|
await system_log_service.create_log(
|
||||||
|
db,
|
||||||
|
SystemLogCreate(
|
||||||
|
level="INFO",
|
||||||
|
type="api",
|
||||||
|
message=f"用户 {current_user.username} 提交考试(考试ID: {request.exam_id},得分: {result.get('score', 0)})",
|
||||||
|
user_id=current_user.id,
|
||||||
|
user=current_user.username,
|
||||||
|
ip=http_request.client.host if http_request.client else None,
|
||||||
|
path="/api/v1/exams/submit",
|
||||||
|
method="POST",
|
||||||
|
user_agent=http_request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(code=200, data=SubmitExamResponse(**result), message="考试提交成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mistakes", response_model=ResponseModel[GetMistakesResponse])
|
||||||
|
async def get_mistakes(
|
||||||
|
exam_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取错题记录
|
||||||
|
|
||||||
|
用于第二、三轮考试时获取上一轮的错题记录
|
||||||
|
返回的数据可直接序列化为JSON字符串作为mistake_records参数传给考试生成接口
|
||||||
|
"""
|
||||||
|
logger.info(f"📋 GET /mistakes 收到请求")
|
||||||
|
try:
|
||||||
|
logger.info(f"📋 获取错题记录 - exam_id: {exam_id}, user_id: {current_user.id}")
|
||||||
|
|
||||||
|
# 查询指定考试的错题记录
|
||||||
|
result = await db.execute(
|
||||||
|
select(ExamMistake).where(
|
||||||
|
ExamMistake.exam_id == exam_id,
|
||||||
|
ExamMistake.user_id == current_user.id,
|
||||||
|
).order_by(ExamMistake.id)
|
||||||
|
)
|
||||||
|
mistakes = result.scalars().all()
|
||||||
|
|
||||||
|
logger.info(f"✅ 查询到错题记录数量: {len(mistakes)}")
|
||||||
|
|
||||||
|
# 转换为响应格式
|
||||||
|
mistake_items = [
|
||||||
|
MistakeRecordItem(
|
||||||
|
id=m.id,
|
||||||
|
question_id=m.question_id,
|
||||||
|
knowledge_point_id=m.knowledge_point_id,
|
||||||
|
question_content=m.question_content,
|
||||||
|
correct_answer=m.correct_answer,
|
||||||
|
user_answer=m.user_answer,
|
||||||
|
created_at=m.created_at,
|
||||||
|
)
|
||||||
|
for m in mistakes
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"获取错题记录成功 - user_id: {current_user.id}, exam_id: {exam_id}, "
|
||||||
|
f"count: {len(mistake_items)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 返回统一的ResponseModel格式,让Pydantic自动处理序列化
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取错题记录成功",
|
||||||
|
data=GetMistakesResponse(
|
||||||
|
mistakes=mistake_items
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取错题记录失败: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取错题记录失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{exam_id}", response_model=ResponseModel[ExamDetailResponse])
|
||||||
|
async def get_exam_detail(
|
||||||
|
exam_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""获取考试详情"""
|
||||||
|
exam_data = await ExamService.get_exam_detail(
|
||||||
|
db=db, user_id=current_user.id, exam_id=exam_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(code=200, data=ExamDetailResponse(**exam_data), message="获取成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/records", response_model=ResponseModel[dict])
|
||||||
|
async def get_exam_records(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(10, ge=1, le=100),
|
||||||
|
course_id: Optional[int] = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""获取考试记录列表"""
|
||||||
|
records = await ExamService.get_exam_records(
|
||||||
|
db=db, user_id=current_user.id, page=page, size=size, course_id=course_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(code=200, data=records, message="获取成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/statistics/summary", response_model=ResponseModel[dict])
|
||||||
|
async def get_exam_statistics(
|
||||||
|
course_id: Optional[int] = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""获取考试统计信息"""
|
||||||
|
stats = await ExamService.get_exam_statistics(
|
||||||
|
db=db, user_id=current_user.id, course_id=course_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(code=200, data=stats, message="获取成功")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 试题生成接口 ====================
|
||||||
|
|
||||||
|
@router.post("/generate", response_model=ResponseModel[GenerateExamResponse])
|
||||||
|
async def generate_exam(
|
||||||
|
request: GenerateExamRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
生成考试试题
|
||||||
|
|
||||||
|
使用 Python 原生 AI 服务实现。
|
||||||
|
|
||||||
|
考试轮次说明:
|
||||||
|
- 第一轮考试:mistake_records 传空或不传
|
||||||
|
- 第二、三轮错题重考:mistake_records 传入上一轮错题记录的JSON字符串
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 从用户信息中自动获取岗位ID(如果未提供)
|
||||||
|
position_id = request.position_id
|
||||||
|
if not position_id:
|
||||||
|
# 1. 首先查询用户已分配的岗位
|
||||||
|
result = await db.execute(
|
||||||
|
select(PositionMember).where(
|
||||||
|
PositionMember.user_id == current_user.id,
|
||||||
|
PositionMember.is_deleted == False
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
position_member = result.scalar_one_or_none()
|
||||||
|
if position_member:
|
||||||
|
position_id = position_member.position_id
|
||||||
|
else:
|
||||||
|
# 2. 如果用户没有岗位,从课程关联的岗位中获取第一个
|
||||||
|
result = await db.execute(
|
||||||
|
select(PositionCourse.position_id).where(
|
||||||
|
PositionCourse.course_id == request.course_id,
|
||||||
|
PositionCourse.is_deleted == False
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
course_position = result.scalar_one_or_none()
|
||||||
|
if course_position:
|
||||||
|
position_id = course_position
|
||||||
|
logger.info(f"用户 {current_user.id} 没有分配岗位,使用课程关联的岗位ID: {position_id}")
|
||||||
|
else:
|
||||||
|
# 3. 如果课程也没有关联岗位,抛出错误
|
||||||
|
logger.warning(f"用户 {current_user.id} 没有分配岗位,且课程 {request.course_id} 未关联任何岗位")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="无法生成试题:用户未分配岗位,且课程未关联任何岗位"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录详细的题型设置(用于调试)
|
||||||
|
logger.info(
|
||||||
|
f"考试题型设置 - 单选:{request.single_choice_count}, 多选:{request.multiple_choice_count}, "
|
||||||
|
f"判断:{request.true_false_count}, 填空:{request.fill_blank_count}, 问答:{request.essay_count}, "
|
||||||
|
f"难度:{request.difficulty_level}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 调用 Python 原生试题生成服务
|
||||||
|
logger.info(
|
||||||
|
f"调用原生试题生成服务 - user_id: {current_user.id}, "
|
||||||
|
f"course_id: {request.course_id}, position_id: {position_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建配置
|
||||||
|
config = ExamGeneratorConfig(
|
||||||
|
course_id=request.course_id,
|
||||||
|
position_id=position_id,
|
||||||
|
single_choice_count=request.single_choice_count or 0,
|
||||||
|
multiple_choice_count=request.multiple_choice_count or 0,
|
||||||
|
true_false_count=request.true_false_count or 0,
|
||||||
|
fill_blank_count=request.fill_blank_count or 0,
|
||||||
|
essay_count=request.essay_count or 0,
|
||||||
|
difficulty_level=request.difficulty_level or 3,
|
||||||
|
mistake_records=request.mistake_records or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 调用原生服务
|
||||||
|
gen_result = await exam_generator_service.generate_exam(db, config)
|
||||||
|
|
||||||
|
if not gen_result.get("success"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="试题生成服务返回失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 将题目列表转为 JSON 字符串(兼容原有前端格式)
|
||||||
|
result_data = json.dumps(gen_result.get("questions", []), ensure_ascii=False)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"试题生成完成 - questions: {gen_result.get('total_count')}, "
|
||||||
|
f"provider: {gen_result.get('ai_provider')}, latency: {gen_result.get('ai_latency_ms')}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
if result_data is None or result_data == "":
|
||||||
|
logger.error(f"试题生成未返回有效结果数据")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="试题生成失败: 未返回结果数据"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建或复用考试记录
|
||||||
|
question_count = sum([
|
||||||
|
request.single_choice_count or 0,
|
||||||
|
request.multiple_choice_count or 0,
|
||||||
|
request.true_false_count or 0,
|
||||||
|
request.fill_blank_count or 0,
|
||||||
|
request.essay_count or 0
|
||||||
|
])
|
||||||
|
|
||||||
|
# 第一轮:创建新的exam记录
|
||||||
|
if request.current_round == 1:
|
||||||
|
exam = Exam(
|
||||||
|
user_id=current_user.id,
|
||||||
|
course_id=request.course_id,
|
||||||
|
exam_name=f"课程{request.course_id}考试",
|
||||||
|
question_count=question_count,
|
||||||
|
total_score=100.0,
|
||||||
|
pass_score=60.0,
|
||||||
|
duration_minutes=60,
|
||||||
|
status="started",
|
||||||
|
start_time=datetime.now(),
|
||||||
|
questions=None,
|
||||||
|
answers=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(exam)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(exam)
|
||||||
|
|
||||||
|
logger.info(f"第{request.current_round}轮:创建考试记录成功 - exam_id: {exam.id}")
|
||||||
|
else:
|
||||||
|
# 第二、三轮:复用已有exam记录
|
||||||
|
if not request.exam_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"第{request.current_round}轮考试必须提供exam_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
exam = await db.get(Exam, request.exam_id)
|
||||||
|
if not exam:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="考试记录不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
if exam.user_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="无权访问此考试记录"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"第{request.current_round}轮:复用考试记录 - exam_id: {exam.id}")
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="试题生成成功",
|
||||||
|
data=GenerateExamResponse(
|
||||||
|
result=result_data,
|
||||||
|
workflow_run_id=f"{gen_result.get('ai_provider')}_{gen_result.get('ai_latency_ms')}ms",
|
||||||
|
task_id=f"native_{request.course_id}",
|
||||||
|
exam_id=exam.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"生成考试试题失败: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"试题生成失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/judge-answer", response_model=ResponseModel[JudgeAnswerResponse])
|
||||||
|
async def judge_answer(
|
||||||
|
request: JudgeAnswerRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
判断主观题答案
|
||||||
|
|
||||||
|
适用于填空题和问答题的答案判断。
|
||||||
|
使用 Python 原生 AI 服务实现。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
f"调用原生答案判断服务 - user_id: {current_user.id}, "
|
||||||
|
f"question: {request.question[:50]}..."
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await answer_judge_service.judge(
|
||||||
|
question=request.question,
|
||||||
|
correct_answer=request.correct_answer,
|
||||||
|
user_answer=request.user_answer,
|
||||||
|
analysis=request.analysis,
|
||||||
|
db=db # 传入 db_session 用于记录调用日志
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"答案判断完成 - is_correct: {result.is_correct}, "
|
||||||
|
f"provider: {result.ai_provider}, latency: {result.ai_latency_ms}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="答案判断完成",
|
||||||
|
data=JudgeAnswerResponse(
|
||||||
|
is_correct=result.is_correct,
|
||||||
|
correct_answer=request.correct_answer,
|
||||||
|
feedback=result.raw_response if not result.is_correct else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"答案判断失败: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"答案判断失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/record-mistake", response_model=ResponseModel[RecordMistakeResponse])
|
||||||
|
async def record_mistake(
|
||||||
|
request: RecordMistakeRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
记录错题
|
||||||
|
|
||||||
|
当用户答错题目时,立即调用此接口记录到错题表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 创建错题记录
|
||||||
|
# 注意:knowledge_point_id暂时设置为None,避免外键约束失败
|
||||||
|
mistake = ExamMistake(
|
||||||
|
user_id=current_user.id,
|
||||||
|
exam_id=request.exam_id,
|
||||||
|
question_id=request.question_id,
|
||||||
|
knowledge_point_id=None, # 暂时设为None,避免外键约束
|
||||||
|
question_content=request.question_content,
|
||||||
|
correct_answer=request.correct_answer,
|
||||||
|
user_answer=request.user_answer,
|
||||||
|
question_type=request.question_type, # 新增:记录题型
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.knowledge_point_id:
|
||||||
|
logger.info(f"原始knowledge_point_id={request.knowledge_point_id},已设置为NULL(待同步生产数据)")
|
||||||
|
|
||||||
|
db.add(mistake)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(mistake)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"记录错题成功 - user_id: {current_user.id}, exam_id: {request.exam_id}, "
|
||||||
|
f"mistake_id: {mistake.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="错题记录成功",
|
||||||
|
data=RecordMistakeResponse(
|
||||||
|
id=mistake.id,
|
||||||
|
created_at=mistake.created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"记录错题失败: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"记录错题失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mistakes-debug")
|
||||||
|
async def get_mistakes_debug(
|
||||||
|
exam_id: int,
|
||||||
|
):
|
||||||
|
"""调试endpoint - 不需要认证"""
|
||||||
|
logger.info(f"🔍 调试 - exam_id: {exam_id}, type: {type(exam_id)}")
|
||||||
|
return {"exam_id": exam_id, "type": str(type(exam_id))}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 成绩报告和错题本相关接口 ====================
|
||||||
|
|
||||||
|
@router.get("/statistics/report", response_model=ResponseModel[ExamReportResponse])
|
||||||
|
async def get_exam_report(
|
||||||
|
start_date: Optional[str] = Query(None, description="开始日期(YYYY-MM-DD)"),
|
||||||
|
end_date: Optional[str] = Query(None, description="结束日期(YYYY-MM-DD)"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取成绩报告汇总
|
||||||
|
|
||||||
|
返回包含概览、趋势、科目分析、最近考试记录的完整报告
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
report_data = await ExamReportService.get_exam_report(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(code=200, data=report_data, message="获取成绩报告成功")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取成绩报告失败: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取成绩报告失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mistakes/list", response_model=ResponseModel[MistakeListResponse])
|
||||||
|
async def get_mistakes_list(
|
||||||
|
exam_id: Optional[int] = Query(None, description="考试ID"),
|
||||||
|
course_id: Optional[int] = Query(None, description="课程ID"),
|
||||||
|
question_type: Optional[str] = Query(None, description="题型(single/multiple/judge/blank/essay)"),
|
||||||
|
search: Optional[str] = Query(None, description="关键词搜索"),
|
||||||
|
start_date: Optional[str] = Query(None, description="开始日期(YYYY-MM-DD)"),
|
||||||
|
end_date: Optional[str] = Query(None, description="结束日期(YYYY-MM-DD)"),
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
size: int = Query(10, ge=1, le=100, description="每页数量"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取错题列表(支持多维度筛选)
|
||||||
|
|
||||||
|
- 不传exam_id时返回用户所有错题
|
||||||
|
- 支持按course_id、question_type、关键词、时间范围筛选
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
mistakes_data = await MistakeService.get_mistakes_list(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
exam_id=exam_id,
|
||||||
|
course_id=course_id,
|
||||||
|
question_type=question_type,
|
||||||
|
search=search,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
page=page,
|
||||||
|
size=size
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(code=200, data=mistakes_data, message="获取错题列表成功")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取错题列表失败: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取错题列表失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mistakes/statistics", response_model=ResponseModel[MistakesStatisticsResponse])
|
||||||
|
async def get_mistakes_statistics(
|
||||||
|
course_id: Optional[int] = Query(None, description="课程ID"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取错题统计数据
|
||||||
|
|
||||||
|
返回按课程、题型、时间维度的统计数据
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stats_data = await MistakeService.get_mistakes_statistics(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
course_id=course_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(code=200, data=stats_data, message="获取错题统计成功")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取错题统计失败: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取错题统计失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{exam_id}/round-score", response_model=ResponseModel[dict])
|
||||||
|
async def update_round_score(
|
||||||
|
exam_id: int,
|
||||||
|
request: UpdateRoundScoreRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
更新某轮的得分
|
||||||
|
|
||||||
|
用于前端每轮考试结束后更新对应轮次的得分
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 查询考试记录
|
||||||
|
exam = await db.get(Exam, exam_id)
|
||||||
|
if not exam:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="考试记录不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证权限
|
||||||
|
if exam.user_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="无权修改此考试记录"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新对应轮次的得分
|
||||||
|
if request.round == 1:
|
||||||
|
exam.round1_score = request.score
|
||||||
|
elif request.round == 2:
|
||||||
|
exam.round2_score = request.score
|
||||||
|
elif request.round == 3:
|
||||||
|
exam.round3_score = request.score
|
||||||
|
# 第三轮默认就是 final
|
||||||
|
request.is_final = True
|
||||||
|
|
||||||
|
# 如果是最终轮次(可能是第1/2轮就全对了),更新总分和状态
|
||||||
|
if request.is_final:
|
||||||
|
exam.score = request.score
|
||||||
|
exam.status = "submitted"
|
||||||
|
# 计算是否通过 (pass_score 为空默认 60)
|
||||||
|
exam.is_passed = request.score >= (exam.pass_score or 60)
|
||||||
|
# 更新结束时间
|
||||||
|
from datetime import datetime
|
||||||
|
exam.end_time = datetime.now()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(f"更新轮次得分成功 - exam_id: {exam_id}, round: {request.round}, score: {request.score}")
|
||||||
|
|
||||||
|
return ResponseModel(code=200, data={"exam_id": exam_id}, message="更新得分成功")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"更新轮次得分失败: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"更新轮次得分失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/mistakes/{mistake_id}/mastered", response_model=ResponseModel)
|
||||||
|
async def mark_mistake_mastered(
|
||||||
|
mistake_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
标记错题为已掌握
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mistake_id: 错题记录ID
|
||||||
|
db: 数据库会话
|
||||||
|
current_user: 当前用户
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResponseModel: 标记结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 查询错题记录
|
||||||
|
stmt = select(ExamMistake).where(
|
||||||
|
ExamMistake.id == mistake_id,
|
||||||
|
ExamMistake.user_id == current_user.id
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
mistake = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not mistake:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="错题记录不存在或无权访问"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新掌握状态
|
||||||
|
from datetime import datetime as dt
|
||||||
|
mistake.mastery_status = 'mastered'
|
||||||
|
mistake.mastered_at = dt.utcnow()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(f"标记错题已掌握成功 - mistake_id: {mistake_id}, user_id: {current_user.id}")
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="已标记为掌握",
|
||||||
|
data={"mistake_id": mistake_id, "mastery_status": "mastered"}
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"标记错题已掌握失败: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"标记失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
201
backend/app/api/v1/knowledge_analysis.py
Normal file
201
backend/app/api/v1/knowledge_analysis.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
知识点分析 API
|
||||||
|
|
||||||
|
使用 Python 原生 AI 服务实现
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_db, get_current_user
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.course_service import course_service
|
||||||
|
from app.services.ai.knowledge_analysis_v2 import knowledge_analysis_service_v2
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/courses/{course_id}/materials/{material_id}/analyze", response_model=ResponseModel[Dict[str, Any]])
|
||||||
|
async def analyze_material_knowledge_points(
|
||||||
|
course_id: int,
|
||||||
|
material_id: int,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
分析单个资料的知识点
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
- **material_id**: 资料ID
|
||||||
|
|
||||||
|
使用 Python 原生 AI 服务:
|
||||||
|
- 本地 AI 服务调用(4sapi.com 首选,OpenRouter 备选)
|
||||||
|
- 多层 JSON 解析兜底
|
||||||
|
- 无外部平台依赖,更稳定
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 验证课程是否存在
|
||||||
|
course = await course_service.get_by_id(db, course_id)
|
||||||
|
if not course:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"课程 {course_id} 不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取资料信息
|
||||||
|
materials = await course_service.get_course_materials(db, course_id=course_id)
|
||||||
|
material = next((m for m in materials if m.id == material_id), None)
|
||||||
|
if not material:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"资料 {material_id} 不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"准备启动知识点分析 - course_id: {course_id}, material_id: {material_id}, "
|
||||||
|
f"file_url: {material.file_url}, user_id: {current_user.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 调用 Python 原生知识点分析服务
|
||||||
|
result = await knowledge_analysis_service_v2.analyze_course_material(
|
||||||
|
db=db,
|
||||||
|
course_id=course_id,
|
||||||
|
material_id=material_id,
|
||||||
|
file_url=material.file_url,
|
||||||
|
course_title=course.name,
|
||||||
|
user_id=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"知识点分析完成 - course_id: {course_id}, material_id: {material_id}, "
|
||||||
|
f"knowledge_points: {result.get('knowledge_points_count', 0)}, "
|
||||||
|
f"provider: {result.get('ai_provider')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建响应
|
||||||
|
response_data = {
|
||||||
|
"message": "知识点分析完成",
|
||||||
|
"course_id": course_id,
|
||||||
|
"material_id": material_id,
|
||||||
|
"status": result.get("status", "completed"),
|
||||||
|
"knowledge_points_count": result.get("knowledge_points_count", 0),
|
||||||
|
"ai_provider": result.get("ai_provider"),
|
||||||
|
"ai_model": result.get("ai_model"),
|
||||||
|
"ai_tokens": result.get("ai_tokens"),
|
||||||
|
"ai_latency_ms": result.get("ai_latency_ms"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
data=response_data,
|
||||||
|
message="知识点分析完成"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"知识点分析失败 - course_id: {course_id}, material_id: {material_id}, error: {e}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"知识点分析失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/courses/{course_id}/reanalyze", response_model=ResponseModel[Dict[str, Any]])
|
||||||
|
async def reanalyze_course_materials(
|
||||||
|
course_id: int,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
重新分析课程的所有资料
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
|
||||||
|
该接口会重新分析课程下的所有资料,提取知识点
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 验证课程是否存在
|
||||||
|
course = await course_service.get_by_id(db, course_id)
|
||||||
|
if not course:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"课程 {course_id} 不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取课程资料信息
|
||||||
|
materials = await course_service.get_course_materials(db, course_id=course_id)
|
||||||
|
|
||||||
|
if not materials:
|
||||||
|
return ResponseModel(
|
||||||
|
data={
|
||||||
|
"message": "该课程暂无资料需要分析",
|
||||||
|
"course_id": course_id,
|
||||||
|
"status": "stopped",
|
||||||
|
"materials_count": 0
|
||||||
|
},
|
||||||
|
message="无资料需要分析"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 调用 Python 原生知识点分析服务
|
||||||
|
result = await knowledge_analysis_service_v2.reanalyze_course_materials(
|
||||||
|
db=db,
|
||||||
|
course_id=course_id,
|
||||||
|
course_title=course.name,
|
||||||
|
user_id=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
data={
|
||||||
|
"message": "课程资料重新分析完成",
|
||||||
|
"course_id": course_id,
|
||||||
|
"status": "completed",
|
||||||
|
"materials_count": result.get("materials_count", 0),
|
||||||
|
"success_count": result.get("success_count", 0),
|
||||||
|
"knowledge_points_count": result.get("knowledge_points_count", 0),
|
||||||
|
"analysis_results": result.get("analysis_results", [])
|
||||||
|
},
|
||||||
|
message="重新分析完成"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"启动课程资料重新分析失败 - course_id: {course_id}, error: {e}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="启动重新分析失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/engines", response_model=ResponseModel[Dict[str, Any]])
|
||||||
|
async def list_analysis_engines():
|
||||||
|
"""
|
||||||
|
获取可用的分析引擎列表
|
||||||
|
"""
|
||||||
|
return ResponseModel(
|
||||||
|
data={
|
||||||
|
"engines": [
|
||||||
|
{
|
||||||
|
"id": "native",
|
||||||
|
"name": "Python 原生实现",
|
||||||
|
"description": "使用本地 AI 服务(4sapi.com + OpenRouter),稳定可靠",
|
||||||
|
"default": True
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default_engine": "native"
|
||||||
|
},
|
||||||
|
message="获取分析引擎列表成功"
|
||||||
|
)
|
||||||
8
backend/app/api/v1/manager/__init__.py
Normal file
8
backend/app/api/v1/manager/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
管理员相关API模块
|
||||||
|
"""
|
||||||
|
from .student_scores import router as student_scores_router
|
||||||
|
from .student_practice import router as student_practice_router
|
||||||
|
|
||||||
|
__all__ = ["student_scores_router", "student_practice_router"]
|
||||||
|
|
||||||
345
backend/app/api/v1/manager/student_practice.py
Normal file
345
backend/app/api/v1/manager/student_practice.py
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
"""
|
||||||
|
管理员查看学员陪练记录API
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, get_db
|
||||||
|
from app.core.logger import logger
|
||||||
|
from app.models.position import Position
|
||||||
|
from app.models.position_member import PositionMember
|
||||||
|
from app.models.practice import PracticeReport, PracticeSession, PracticeDialogue
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.base import PaginatedResponse, ResponseModel
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/manager/student-practice", tags=["manager-student-practice"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=ResponseModel[PaginatedResponse])
|
||||||
|
async def get_student_practice_records(
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
student_name: Optional[str] = Query(None, description="学员姓名搜索"),
|
||||||
|
position: Optional[str] = Query(None, description="岗位筛选"),
|
||||||
|
scene_type: Optional[str] = Query(None, description="场景类型筛选"),
|
||||||
|
result: Optional[str] = Query(None, description="结果筛选: excellent/good/average/needs_improvement"),
|
||||||
|
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||||||
|
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取所有用户的陪练记录列表(管理员和manager可访问)
|
||||||
|
|
||||||
|
包含所有角色(trainee/admin/manager)的陪练记录,方便测试和全面管理
|
||||||
|
|
||||||
|
支持筛选:
|
||||||
|
- student_name: 按用户姓名模糊搜索
|
||||||
|
- position: 按岗位筛选
|
||||||
|
- scene_type: 按场景类型筛选
|
||||||
|
- result: 按结果筛选(优秀/良好/一般/需改进)
|
||||||
|
- start_date/end_date: 按日期范围筛选
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 权限检查
|
||||||
|
if current_user.role not in ['admin', 'manager']:
|
||||||
|
return ResponseModel(code=403, message="无权访问", data=None)
|
||||||
|
|
||||||
|
# 构建基础查询
|
||||||
|
# 关联User、PracticeReport来获取完整信息
|
||||||
|
query = (
|
||||||
|
select(
|
||||||
|
PracticeSession,
|
||||||
|
User.full_name.label('student_name'),
|
||||||
|
User.id.label('student_id'),
|
||||||
|
PracticeReport.total_score
|
||||||
|
)
|
||||||
|
.join(User, PracticeSession.user_id == User.id)
|
||||||
|
.outerjoin(
|
||||||
|
PracticeReport,
|
||||||
|
PracticeSession.session_id == PracticeReport.session_id
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
# 管理员可以查看所有人的陪练记录(包括其他管理员的),方便测试和全面管理
|
||||||
|
PracticeSession.status == 'completed', # 只查询已完成的陪练
|
||||||
|
PracticeSession.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 学员姓名筛选
|
||||||
|
if student_name:
|
||||||
|
query = query.where(User.full_name.contains(student_name))
|
||||||
|
|
||||||
|
# 岗位筛选
|
||||||
|
if position:
|
||||||
|
# 通过position_members关联查询
|
||||||
|
query = query.join(
|
||||||
|
PositionMember,
|
||||||
|
and_(
|
||||||
|
PositionMember.user_id == User.id,
|
||||||
|
PositionMember.is_deleted == False
|
||||||
|
)
|
||||||
|
).join(
|
||||||
|
Position,
|
||||||
|
Position.id == PositionMember.position_id
|
||||||
|
).where(
|
||||||
|
Position.name == position
|
||||||
|
)
|
||||||
|
|
||||||
|
# 场景类型筛选
|
||||||
|
if scene_type:
|
||||||
|
query = query.where(PracticeSession.scene_type == scene_type)
|
||||||
|
|
||||||
|
# 结果筛选(根据分数)
|
||||||
|
if result:
|
||||||
|
if result == 'excellent':
|
||||||
|
query = query.where(PracticeReport.total_score >= 90)
|
||||||
|
elif result == 'good':
|
||||||
|
query = query.where(and_(
|
||||||
|
PracticeReport.total_score >= 80,
|
||||||
|
PracticeReport.total_score < 90
|
||||||
|
))
|
||||||
|
elif result == 'average':
|
||||||
|
query = query.where(and_(
|
||||||
|
PracticeReport.total_score >= 70,
|
||||||
|
PracticeReport.total_score < 80
|
||||||
|
))
|
||||||
|
elif result == 'needs_improvement':
|
||||||
|
query = query.where(PracticeReport.total_score < 70)
|
||||||
|
|
||||||
|
# 日期范围筛选
|
||||||
|
if start_date:
|
||||||
|
try:
|
||||||
|
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
query = query.where(PracticeSession.start_time >= start_dt)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
try:
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
||||||
|
query = query.where(PracticeSession.start_time <= end_dt)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 按开始时间倒序
|
||||||
|
query = query.order_by(PracticeSession.start_time.desc())
|
||||||
|
|
||||||
|
# 计算总数
|
||||||
|
count_query = select(func.count()).select_from(query.subquery())
|
||||||
|
total_result = await db.execute(count_query)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# 分页查询
|
||||||
|
offset = (page - 1) * size
|
||||||
|
results = await db.execute(query.offset(offset).limit(size))
|
||||||
|
|
||||||
|
# 构建响应数据
|
||||||
|
items = []
|
||||||
|
for session, student_name, student_id, total_score in results:
|
||||||
|
# 查询该学员的所有岗位
|
||||||
|
position_query = (
|
||||||
|
select(Position.name)
|
||||||
|
.join(PositionMember, Position.id == PositionMember.position_id)
|
||||||
|
.where(
|
||||||
|
PositionMember.user_id == student_id,
|
||||||
|
PositionMember.is_deleted == False,
|
||||||
|
Position.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
position_result = await db.execute(position_query)
|
||||||
|
positions = position_result.scalars().all()
|
||||||
|
position_str = ', '.join(positions) if positions else None
|
||||||
|
|
||||||
|
# 根据分数计算结果等级
|
||||||
|
result_level = "needs_improvement"
|
||||||
|
if total_score:
|
||||||
|
if total_score >= 90:
|
||||||
|
result_level = "excellent"
|
||||||
|
elif total_score >= 80:
|
||||||
|
result_level = "good"
|
||||||
|
elif total_score >= 70:
|
||||||
|
result_level = "average"
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"id": session.id,
|
||||||
|
"student_id": student_id,
|
||||||
|
"student_name": student_name,
|
||||||
|
"position": position_str, # 所有岗位,逗号分隔
|
||||||
|
"session_id": session.session_id,
|
||||||
|
"scene_name": session.scene_name,
|
||||||
|
"scene_type": session.scene_type,
|
||||||
|
"duration_seconds": session.duration_seconds,
|
||||||
|
"round_count": session.turns, # turns字段表示对话轮数
|
||||||
|
"score": total_score,
|
||||||
|
"result": result_level,
|
||||||
|
"practice_time": session.start_time.strftime('%Y-%m-%d %H:%M:%S') if session.start_time else None
|
||||||
|
})
|
||||||
|
|
||||||
|
# 计算分页信息
|
||||||
|
pages = (total + size - 1) // size
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data=PaginatedResponse(
|
||||||
|
items=items,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=size,
|
||||||
|
pages=pages
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取学员陪练记录失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"获取学员陪练记录失败: {str(e)}", data=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/statistics", response_model=ResponseModel)
|
||||||
|
async def get_student_practice_statistics(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取学员陪练统计数据
|
||||||
|
|
||||||
|
返回:
|
||||||
|
- total_count: 总陪练次数
|
||||||
|
- avg_score: 平均评分
|
||||||
|
- total_duration_hours: 总陪练时长(小时)
|
||||||
|
- excellent_rate: 优秀率
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 权限检查
|
||||||
|
if current_user.role not in ['admin', 'manager']:
|
||||||
|
return ResponseModel(code=403, message="无权访问", data=None)
|
||||||
|
|
||||||
|
# 查询所有已完成陪练(包括所有角色)
|
||||||
|
query = (
|
||||||
|
select(PracticeSession, PracticeReport.total_score)
|
||||||
|
.join(User, PracticeSession.user_id == User.id)
|
||||||
|
.outerjoin(
|
||||||
|
PracticeReport,
|
||||||
|
PracticeSession.session_id == PracticeReport.session_id
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
PracticeSession.status == 'completed',
|
||||||
|
PracticeSession.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
records = result.all()
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data={
|
||||||
|
"total_count": 0,
|
||||||
|
"avg_score": 0,
|
||||||
|
"total_duration_hours": 0,
|
||||||
|
"excellent_rate": 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
total_count = len(records)
|
||||||
|
|
||||||
|
# 计算总时长(秒转小时)
|
||||||
|
total_duration_seconds = sum(
|
||||||
|
session.duration_seconds for session, _ in records if session.duration_seconds
|
||||||
|
)
|
||||||
|
total_duration_hours = round(total_duration_seconds / 3600, 1)
|
||||||
|
|
||||||
|
# 计算平均分
|
||||||
|
scores = [score for _, score in records if score is not None]
|
||||||
|
avg_score = round(sum(scores) / len(scores), 1) if scores else 0
|
||||||
|
|
||||||
|
# 计算优秀率(>=90分)
|
||||||
|
excellent = sum(1 for _, score in records if score and score >= 90)
|
||||||
|
excellent_rate = round((excellent / total_count) * 100, 1) if total_count > 0 else 0
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data={
|
||||||
|
"total_count": total_count,
|
||||||
|
"avg_score": avg_score,
|
||||||
|
"total_duration_hours": total_duration_hours,
|
||||||
|
"excellent_rate": excellent_rate
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取学员陪练统计失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"获取学员陪练统计失败: {str(e)}", data=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}/conversation", response_model=ResponseModel)
|
||||||
|
async def get_session_conversation(
|
||||||
|
session_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取指定会话的对话记录
|
||||||
|
|
||||||
|
返回对话列表,按sequence排序
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 权限检查
|
||||||
|
if current_user.role not in ['admin', 'manager']:
|
||||||
|
return ResponseModel(code=403, message="无权访问", data=None)
|
||||||
|
|
||||||
|
# 1. 查询会话是否存在
|
||||||
|
session_query = select(PracticeSession).where(
|
||||||
|
PracticeSession.session_id == session_id,
|
||||||
|
PracticeSession.is_deleted == False
|
||||||
|
)
|
||||||
|
session_result = await db.execute(session_query)
|
||||||
|
session = session_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
return ResponseModel(code=404, message="会话不存在", data=None)
|
||||||
|
|
||||||
|
# 2. 查询对话记录
|
||||||
|
dialogue_query = (
|
||||||
|
select(PracticeDialogue)
|
||||||
|
.where(PracticeDialogue.session_id == session_id)
|
||||||
|
.order_by(PracticeDialogue.sequence)
|
||||||
|
)
|
||||||
|
dialogue_result = await db.execute(dialogue_query)
|
||||||
|
dialogues = dialogue_result.scalars().all()
|
||||||
|
|
||||||
|
# 3. 构建响应数据
|
||||||
|
conversation = []
|
||||||
|
for dialogue in dialogues:
|
||||||
|
conversation.append({
|
||||||
|
"role": dialogue.speaker, # "user" 或 "ai"
|
||||||
|
"content": dialogue.content,
|
||||||
|
"timestamp": dialogue.timestamp.strftime('%Y-%m-%d %H:%M:%S') if dialogue.timestamp else None,
|
||||||
|
"sequence": dialogue.sequence
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"获取会话对话记录: session_id={session_id}, 对话数={len(conversation)}")
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data={
|
||||||
|
"session_id": session_id,
|
||||||
|
"conversation": conversation,
|
||||||
|
"total_count": len(conversation)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取会话对话记录失败: {e}, session_id={session_id}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"获取对话记录失败: {str(e)}", data=None)
|
||||||
|
|
||||||
447
backend/app/api/v1/manager/student_scores.py
Normal file
447
backend/app/api/v1/manager/student_scores.py
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
"""
|
||||||
|
管理员查看学员考试成绩API
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import and_, delete, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, get_db
|
||||||
|
from app.core.logger import logger
|
||||||
|
from app.models.course import Course
|
||||||
|
from app.models.exam import Exam
|
||||||
|
from app.models.exam_mistake import ExamMistake
|
||||||
|
from app.models.position_member import PositionMember
|
||||||
|
from app.models.position import Position
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.base import PaginatedResponse, ResponseModel
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/manager/student-scores", tags=["manager-student-scores"])
|
||||||
|
|
||||||
|
|
||||||
|
class BatchDeleteRequest(BaseModel):
|
||||||
|
"""批量删除请求"""
|
||||||
|
ids: List[int]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{exam_id}/mistakes", response_model=ResponseModel[PaginatedResponse])
|
||||||
|
async def get_exam_mistakes(
|
||||||
|
exam_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取指定考试的错题记录(管理员和manager可访问)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 权限检查
|
||||||
|
if current_user.role not in ['admin', 'manager']:
|
||||||
|
return ResponseModel(code=403, message="无权访问", data=None)
|
||||||
|
|
||||||
|
# 查询错题记录
|
||||||
|
query = (
|
||||||
|
select(ExamMistake)
|
||||||
|
.options(selectinload(ExamMistake.question))
|
||||||
|
.where(ExamMistake.exam_id == exam_id)
|
||||||
|
.order_by(ExamMistake.created_at.desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
mistakes = result.scalars().all()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for mistake in mistakes:
|
||||||
|
# 获取解析:优先从关联题目获取,如果是AI生成的题目可能没有关联题目
|
||||||
|
analysis = ""
|
||||||
|
if mistake.question and mistake.question.explanation:
|
||||||
|
analysis = mistake.question.explanation
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"id": mistake.id,
|
||||||
|
"question_content": mistake.question_content,
|
||||||
|
"correct_answer": mistake.correct_answer,
|
||||||
|
"user_answer": mistake.user_answer,
|
||||||
|
"question_type": mistake.question_type,
|
||||||
|
"analysis": analysis,
|
||||||
|
"created_at": mistake.created_at.strftime('%Y-%m-%d %H:%M:%S') if mistake.created_at else None
|
||||||
|
})
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data=PaginatedResponse(
|
||||||
|
items=items,
|
||||||
|
total=len(items),
|
||||||
|
page=1,
|
||||||
|
page_size=len(items),
|
||||||
|
pages=1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取错题记录失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"获取错题记录失败: {str(e)}", data=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=ResponseModel[PaginatedResponse])
|
||||||
|
async def get_student_scores(
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
student_name: Optional[str] = Query(None, description="学员姓名搜索"),
|
||||||
|
position: Optional[str] = Query(None, description="岗位筛选"),
|
||||||
|
course_id: Optional[int] = Query(None, description="课程ID筛选"),
|
||||||
|
score_range: Optional[str] = Query(None, description="成绩范围: excellent/good/pass/fail"),
|
||||||
|
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||||||
|
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取所有学员的考试成绩列表(管理员和manager可访问)
|
||||||
|
|
||||||
|
支持筛选:
|
||||||
|
- student_name: 按学员姓名模糊搜索
|
||||||
|
- position: 按岗位筛选
|
||||||
|
- course_id: 按课程筛选
|
||||||
|
- score_range: 按成绩范围筛选(excellent>=90, good>=80, pass>=60, fail<60)
|
||||||
|
- start_date/end_date: 按日期范围筛选
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 权限检查
|
||||||
|
if current_user.role not in ['admin', 'manager']:
|
||||||
|
return ResponseModel(code=403, message="无权访问", data=None)
|
||||||
|
|
||||||
|
# 构建基础查询
|
||||||
|
# 关联User、Course、ExamMistake来获取完整信息
|
||||||
|
query = (
|
||||||
|
select(
|
||||||
|
Exam,
|
||||||
|
User.full_name.label('student_name'),
|
||||||
|
User.id.label('student_id'),
|
||||||
|
Course.name.label('course_name'),
|
||||||
|
func.count(ExamMistake.id).label('wrong_count')
|
||||||
|
)
|
||||||
|
.join(User, Exam.user_id == User.id)
|
||||||
|
.join(Course, Exam.course_id == Course.id)
|
||||||
|
.outerjoin(ExamMistake, and_(
|
||||||
|
ExamMistake.exam_id == Exam.id,
|
||||||
|
ExamMistake.user_id == User.id
|
||||||
|
))
|
||||||
|
.where(
|
||||||
|
Exam.status.in_(['completed', 'submitted']) # 只查询已完成的考试
|
||||||
|
)
|
||||||
|
.group_by(Exam.id, User.id, User.full_name, Course.id, Course.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 学员姓名筛选
|
||||||
|
if student_name:
|
||||||
|
query = query.where(User.full_name.contains(student_name))
|
||||||
|
|
||||||
|
# 岗位筛选
|
||||||
|
if position:
|
||||||
|
# 通过position_members关联查询
|
||||||
|
query = query.join(
|
||||||
|
PositionMember,
|
||||||
|
and_(
|
||||||
|
PositionMember.user_id == User.id,
|
||||||
|
PositionMember.is_deleted == False
|
||||||
|
)
|
||||||
|
).join(
|
||||||
|
Position,
|
||||||
|
Position.id == PositionMember.position_id
|
||||||
|
).where(
|
||||||
|
Position.name == position
|
||||||
|
)
|
||||||
|
|
||||||
|
# 课程筛选
|
||||||
|
if course_id:
|
||||||
|
query = query.where(Exam.course_id == course_id)
|
||||||
|
|
||||||
|
# 成绩范围筛选
|
||||||
|
if score_range:
|
||||||
|
score_field = Exam.round1_score # 使用第一轮成绩
|
||||||
|
if score_range == 'excellent':
|
||||||
|
query = query.where(score_field >= 90)
|
||||||
|
elif score_range == 'good':
|
||||||
|
query = query.where(and_(score_field >= 80, score_field < 90))
|
||||||
|
elif score_range == 'pass':
|
||||||
|
query = query.where(and_(score_field >= 60, score_field < 80))
|
||||||
|
elif score_range == 'fail':
|
||||||
|
query = query.where(score_field < 60)
|
||||||
|
|
||||||
|
# 日期范围筛选
|
||||||
|
if start_date:
|
||||||
|
try:
|
||||||
|
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
query = query.where(Exam.created_at >= start_dt)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
try:
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
||||||
|
query = query.where(Exam.created_at <= end_dt)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 按创建时间倒序
|
||||||
|
query = query.order_by(Exam.created_at.desc())
|
||||||
|
|
||||||
|
# 计算总数
|
||||||
|
count_query = select(func.count()).select_from(query.subquery())
|
||||||
|
total_result = await db.execute(count_query)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# 分页查询
|
||||||
|
offset = (page - 1) * size
|
||||||
|
results = await db.execute(query.offset(offset).limit(size))
|
||||||
|
|
||||||
|
# 构建响应数据
|
||||||
|
items = []
|
||||||
|
for exam, student_name, student_id, course_name, wrong_count in results:
|
||||||
|
# 查询该学员的所有岗位
|
||||||
|
position_query = (
|
||||||
|
select(Position.name)
|
||||||
|
.join(PositionMember, Position.id == PositionMember.position_id)
|
||||||
|
.where(
|
||||||
|
PositionMember.user_id == student_id,
|
||||||
|
PositionMember.is_deleted == False,
|
||||||
|
Position.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
position_result = await db.execute(position_query)
|
||||||
|
positions = position_result.scalars().all()
|
||||||
|
position_str = ', '.join(positions) if positions else None
|
||||||
|
|
||||||
|
# 计算正确率和用时
|
||||||
|
accuracy = None
|
||||||
|
correct_count = None
|
||||||
|
duration_seconds = None
|
||||||
|
|
||||||
|
if exam.question_count and exam.question_count > 0:
|
||||||
|
correct_count = exam.question_count - wrong_count
|
||||||
|
accuracy = round((correct_count / exam.question_count) * 100, 1)
|
||||||
|
|
||||||
|
if exam.start_time and exam.end_time:
|
||||||
|
duration_seconds = int((exam.end_time - exam.start_time).total_seconds())
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"id": exam.id,
|
||||||
|
"student_id": student_id,
|
||||||
|
"student_name": student_name,
|
||||||
|
"position": position_str, # 所有岗位,逗号分隔
|
||||||
|
"course_id": exam.course_id,
|
||||||
|
"course_name": course_name,
|
||||||
|
"exam_type": "assessment", # 简化处理,统一为assessment
|
||||||
|
"score": float(exam.round1_score) if exam.round1_score else 0,
|
||||||
|
"round1_score": float(exam.round1_score) if exam.round1_score else None,
|
||||||
|
"round2_score": float(exam.round2_score) if exam.round2_score else None,
|
||||||
|
"round3_score": float(exam.round3_score) if exam.round3_score else None,
|
||||||
|
"total_score": float(exam.total_score) if exam.total_score else 100,
|
||||||
|
"accuracy": accuracy,
|
||||||
|
"correct_count": correct_count,
|
||||||
|
"wrong_count": wrong_count,
|
||||||
|
"total_count": exam.question_count,
|
||||||
|
"duration_seconds": duration_seconds,
|
||||||
|
"exam_date": exam.created_at.strftime('%Y-%m-%d %H:%M:%S') if exam.created_at else None
|
||||||
|
})
|
||||||
|
|
||||||
|
# 计算分页信息
|
||||||
|
pages = (total + size - 1) // size
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data=PaginatedResponse(
|
||||||
|
items=items,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=size,
|
||||||
|
pages=pages
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取学员考试成绩失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"获取学员考试成绩失败: {str(e)}", data=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/statistics", response_model=ResponseModel)
|
||||||
|
async def get_student_scores_statistics(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取学员考试成绩统计数据
|
||||||
|
|
||||||
|
返回:
|
||||||
|
- total_exams: 总考试次数
|
||||||
|
- avg_score: 平均分
|
||||||
|
- pass_rate: 通过率
|
||||||
|
- excellent_rate: 优秀率
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 权限检查
|
||||||
|
if current_user.role not in ['admin', 'manager']:
|
||||||
|
return ResponseModel(code=403, message="无权访问", data=None)
|
||||||
|
|
||||||
|
# 查询所有用户的已完成考试
|
||||||
|
query = (
|
||||||
|
select(Exam)
|
||||||
|
.join(User, Exam.user_id == User.id)
|
||||||
|
.where(
|
||||||
|
Exam.status.in_(['completed', 'submitted']),
|
||||||
|
Exam.round1_score.isnot(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
exams = result.scalars().all()
|
||||||
|
|
||||||
|
if not exams:
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data={
|
||||||
|
"total_exams": 0,
|
||||||
|
"avg_score": 0,
|
||||||
|
"pass_rate": 0,
|
||||||
|
"excellent_rate": 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
total_exams = len(exams)
|
||||||
|
total_score = sum(exam.round1_score for exam in exams if exam.round1_score)
|
||||||
|
avg_score = round(total_score / total_exams, 1) if total_exams > 0 else 0
|
||||||
|
|
||||||
|
passed = sum(1 for exam in exams if exam.round1_score and exam.round1_score >= 60)
|
||||||
|
pass_rate = round((passed / total_exams) * 100, 1) if total_exams > 0 else 0
|
||||||
|
|
||||||
|
excellent = sum(1 for exam in exams if exam.round1_score and exam.round1_score >= 90)
|
||||||
|
excellent_rate = round((excellent / total_exams) * 100, 1) if total_exams > 0 else 0
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data={
|
||||||
|
"total_exams": total_exams,
|
||||||
|
"avg_score": avg_score,
|
||||||
|
"pass_rate": pass_rate,
|
||||||
|
"excellent_rate": excellent_rate
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取学员考试成绩统计失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"获取学员考试成绩统计失败: {str(e)}", data=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{exam_id}", response_model=ResponseModel)
|
||||||
|
async def delete_exam_record(
|
||||||
|
exam_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
删除单条考试记录(管理员可访问)
|
||||||
|
|
||||||
|
会同时删除关联的错题记录
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 权限检查 - 仅管理员可删除
|
||||||
|
if current_user.role != 'admin':
|
||||||
|
return ResponseModel(code=403, message="无权操作,仅管理员可删除考试记录", data=None)
|
||||||
|
|
||||||
|
# 查询考试记录
|
||||||
|
result = await db.execute(
|
||||||
|
select(Exam).where(Exam.id == exam_id)
|
||||||
|
)
|
||||||
|
exam = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not exam:
|
||||||
|
return ResponseModel(code=404, message="考试记录不存在", data=None)
|
||||||
|
|
||||||
|
# 删除关联的错题记录
|
||||||
|
await db.execute(
|
||||||
|
delete(ExamMistake).where(ExamMistake.exam_id == exam_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 删除考试记录
|
||||||
|
await db.delete(exam)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(f"管理员 {current_user.username} 删除了考试记录 {exam_id}")
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="考试记录已删除",
|
||||||
|
data={"deleted_id": exam_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"删除考试记录失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"删除考试记录失败: {str(e)}", data=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/batch/delete", response_model=ResponseModel)
|
||||||
|
async def batch_delete_exam_records(
|
||||||
|
request: BatchDeleteRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
批量删除考试记录(管理员可访问)
|
||||||
|
|
||||||
|
会同时删除关联的错题记录
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 权限检查 - 仅管理员可删除
|
||||||
|
if current_user.role != 'admin':
|
||||||
|
return ResponseModel(code=403, message="无权操作,仅管理员可删除考试记录", data=None)
|
||||||
|
|
||||||
|
if not request.ids:
|
||||||
|
return ResponseModel(code=400, message="请选择要删除的记录", data=None)
|
||||||
|
|
||||||
|
# 查询存在的考试记录
|
||||||
|
result = await db.execute(
|
||||||
|
select(Exam.id).where(Exam.id.in_(request.ids))
|
||||||
|
)
|
||||||
|
existing_ids = [row[0] for row in result.all()]
|
||||||
|
|
||||||
|
if not existing_ids:
|
||||||
|
return ResponseModel(code=404, message="未找到要删除的记录", data=None)
|
||||||
|
|
||||||
|
# 删除关联的错题记录
|
||||||
|
await db.execute(
|
||||||
|
delete(ExamMistake).where(ExamMistake.exam_id.in_(existing_ids))
|
||||||
|
)
|
||||||
|
|
||||||
|
# 删除考试记录
|
||||||
|
await db.execute(
|
||||||
|
delete(Exam).where(Exam.id.in_(existing_ids))
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
deleted_count = len(existing_ids)
|
||||||
|
logger.info(f"管理员 {current_user.username} 批量删除了 {deleted_count} 条考试记录")
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message=f"成功删除 {deleted_count} 条考试记录",
|
||||||
|
data={
|
||||||
|
"deleted_count": deleted_count,
|
||||||
|
"deleted_ids": existing_ids
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"批量删除考试记录失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"批量删除考试记录失败: {str(e)}", data=None)
|
||||||
|
|
||||||
255
backend/app/api/v1/notifications.py
Normal file
255
backend/app/api/v1/notifications.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"""
|
||||||
|
站内消息通知 API
|
||||||
|
提供通知的查询、标记已读、删除等功能
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_db, get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
from app.schemas.notification import (
|
||||||
|
NotificationCreate,
|
||||||
|
NotificationBatchCreate,
|
||||||
|
NotificationResponse,
|
||||||
|
NotificationListResponse,
|
||||||
|
NotificationCountResponse,
|
||||||
|
MarkReadRequest,
|
||||||
|
)
|
||||||
|
from app.services.notification_service import notification_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/notifications")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ResponseModel[NotificationListResponse])
|
||||||
|
async def get_notifications(
|
||||||
|
is_read: Optional[bool] = Query(None, description="是否已读筛选"),
|
||||||
|
type: Optional[str] = Query(None, description="通知类型筛选"),
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取当前用户的通知列表
|
||||||
|
|
||||||
|
支持按已读状态和通知类型筛选
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
skip = (page - 1) * page_size
|
||||||
|
|
||||||
|
notifications, total, unread_count = await notification_service.get_user_notifications(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
skip=skip,
|
||||||
|
limit=page_size,
|
||||||
|
is_read=is_read,
|
||||||
|
notification_type=type
|
||||||
|
)
|
||||||
|
|
||||||
|
response_data = NotificationListResponse(
|
||||||
|
items=notifications,
|
||||||
|
total=total,
|
||||||
|
unread_count=unread_count
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取通知列表成功",
|
||||||
|
data=response_data
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取通知列表失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取通知列表失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unread-count", response_model=ResponseModel[NotificationCountResponse])
|
||||||
|
async def get_unread_count(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取当前用户的未读通知数量
|
||||||
|
|
||||||
|
用于顶部导航栏显示未读消息数
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
unread_count, total = await notification_service.get_unread_count(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取未读数量成功",
|
||||||
|
data=NotificationCountResponse(
|
||||||
|
unread_count=unread_count,
|
||||||
|
total=total
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取未读数量失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取未读数量失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mark-read", response_model=ResponseModel)
|
||||||
|
async def mark_notifications_read(
|
||||||
|
request: MarkReadRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
标记通知为已读
|
||||||
|
|
||||||
|
- 传入 notification_ids 则标记指定通知
|
||||||
|
- 不传则标记全部未读通知为已读
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
updated_count = await notification_service.mark_as_read(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
notification_ids=request.notification_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message=f"成功标记 {updated_count} 条通知为已读",
|
||||||
|
data={"updated_count": updated_count}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"标记已读失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"标记已读失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{notification_id}", response_model=ResponseModel)
|
||||||
|
async def delete_notification(
|
||||||
|
notification_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除单条通知
|
||||||
|
|
||||||
|
只能删除自己的通知
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
success = await notification_service.delete_notification(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
notification_id=notification_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="通知不存在或无权删除")
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="删除通知成功",
|
||||||
|
data={"deleted": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"删除通知失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"删除通知失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 管理员接口 ====================
|
||||||
|
|
||||||
|
@router.post("/send", response_model=ResponseModel[NotificationResponse])
|
||||||
|
async def send_notification(
|
||||||
|
notification_in: NotificationCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
发送单条通知(管理员接口)
|
||||||
|
|
||||||
|
向指定用户发送通知
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 权限检查:仅管理员和管理者可发送通知
|
||||||
|
if current_user.role not in ["admin", "manager"]:
|
||||||
|
raise HTTPException(status_code=403, detail="无权限发送通知")
|
||||||
|
|
||||||
|
# 设置发送者
|
||||||
|
notification_in.sender_id = current_user.id
|
||||||
|
|
||||||
|
notification = await notification_service.create_notification(
|
||||||
|
db=db,
|
||||||
|
notification_in=notification_in
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建响应
|
||||||
|
response = NotificationResponse(
|
||||||
|
id=notification.id,
|
||||||
|
user_id=notification.user_id,
|
||||||
|
title=notification.title,
|
||||||
|
content=notification.content,
|
||||||
|
type=notification.type,
|
||||||
|
is_read=notification.is_read,
|
||||||
|
related_id=notification.related_id,
|
||||||
|
related_type=notification.related_type,
|
||||||
|
sender_id=notification.sender_id,
|
||||||
|
sender_name=current_user.full_name,
|
||||||
|
created_at=notification.created_at,
|
||||||
|
updated_at=notification.updated_at
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="发送通知成功",
|
||||||
|
data=response
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"发送通知失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"发送通知失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/send-batch", response_model=ResponseModel)
|
||||||
|
async def send_batch_notifications(
|
||||||
|
batch_in: NotificationBatchCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
批量发送通知(管理员接口)
|
||||||
|
|
||||||
|
向多个用户发送相同的通知
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 权限检查:仅管理员和管理者可发送通知
|
||||||
|
if current_user.role not in ["admin", "manager"]:
|
||||||
|
raise HTTPException(status_code=403, detail="无权限发送通知")
|
||||||
|
|
||||||
|
# 设置发送者
|
||||||
|
batch_in.sender_id = current_user.id
|
||||||
|
|
||||||
|
notifications = await notification_service.batch_create_notifications(
|
||||||
|
db=db,
|
||||||
|
batch_in=batch_in
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message=f"成功发送 {len(notifications)} 条通知",
|
||||||
|
data={"sent_count": len(notifications)}
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"批量发送通知失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"批量发送通知失败: {str(e)}")
|
||||||
|
|
||||||
658
backend/app/api/v1/positions.py
Normal file
658
backend/app/api/v1/positions.py
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
"""
|
||||||
|
岗位管理 API(真实数据库)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, List
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, and_, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from app.core.deps import get_current_active_user as get_current_user, get_db, require_admin, require_admin_or_manager
|
||||||
|
from app.schemas.base import ResponseModel, PaginationParams, PaginatedResponse
|
||||||
|
from app.models.position import Position
|
||||||
|
from app.models.position_member import PositionMember
|
||||||
|
from app.models.position_course import PositionCourse
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.course import Course
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/positions")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_positions(
|
||||||
|
pagination: PaginationParams = Depends(),
|
||||||
|
keyword: Optional[str] = Query(None, description="关键词"),
|
||||||
|
current_user=Depends(require_admin_or_manager),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""分页获取岗位列表(管理员或经理)。"""
|
||||||
|
stmt = select(Position).where(Position.is_deleted == False)
|
||||||
|
if keyword:
|
||||||
|
like = f"%{keyword}%"
|
||||||
|
stmt = stmt.where((Position.name.ilike(like)) | (Position.description.ilike(like)))
|
||||||
|
rows = (await db.execute(stmt)).scalars().all()
|
||||||
|
total = len(rows)
|
||||||
|
sliced = rows[pagination.offset : pagination.offset + pagination.limit]
|
||||||
|
|
||||||
|
async def to_dict(p: Position) -> dict:
|
||||||
|
"""将Position对象转换为字典,并添加统计数据"""
|
||||||
|
d = p.__dict__.copy()
|
||||||
|
d.pop("_sa_instance_state", None)
|
||||||
|
|
||||||
|
# 统计岗位成员数量
|
||||||
|
member_count_result = await db.execute(
|
||||||
|
select(func.count(PositionMember.id)).where(
|
||||||
|
and_(
|
||||||
|
PositionMember.position_id == p.id,
|
||||||
|
PositionMember.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
d["memberCount"] = member_count_result.scalar() or 0
|
||||||
|
|
||||||
|
# 统计必修课程数量
|
||||||
|
required_count_result = await db.execute(
|
||||||
|
select(func.count(PositionCourse.id)).where(
|
||||||
|
and_(
|
||||||
|
PositionCourse.position_id == p.id,
|
||||||
|
PositionCourse.course_type == "required",
|
||||||
|
PositionCourse.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
d["requiredCourses"] = required_count_result.scalar() or 0
|
||||||
|
|
||||||
|
# 统计选修课程数量
|
||||||
|
optional_count_result = await db.execute(
|
||||||
|
select(func.count(PositionCourse.id)).where(
|
||||||
|
and_(
|
||||||
|
PositionCourse.position_id == p.id,
|
||||||
|
PositionCourse.course_type == "optional",
|
||||||
|
PositionCourse.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
d["optionalCourses"] = optional_count_result.scalar() or 0
|
||||||
|
|
||||||
|
return d
|
||||||
|
|
||||||
|
# 为每个岗位添加统计数据(使用异步)
|
||||||
|
items = []
|
||||||
|
for p in sliced:
|
||||||
|
item = await to_dict(p)
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
paged = {
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"page": pagination.page,
|
||||||
|
"page_size": pagination.page_size,
|
||||||
|
"pages": (total + pagination.page_size - 1) // pagination.page_size if pagination.page_size else 1,
|
||||||
|
}
|
||||||
|
return ResponseModel(message="获取岗位列表成功", data=paged)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tree")
|
||||||
|
async def get_position_tree(
|
||||||
|
current_user=Depends(require_admin_or_manager), db: AsyncSession = Depends(get_db)
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""获取岗位树(管理员或经理)。"""
|
||||||
|
rows = (await db.execute(select(Position).where(Position.is_deleted == False))).scalars().all()
|
||||||
|
id_to_node = {p.id: {**p.__dict__, "children": []} for p in rows}
|
||||||
|
roots: List[dict] = []
|
||||||
|
for p in rows:
|
||||||
|
node = id_to_node[p.id]
|
||||||
|
parent_id = p.parent_id
|
||||||
|
if parent_id and parent_id in id_to_node:
|
||||||
|
id_to_node[parent_id]["children"].append(node)
|
||||||
|
else:
|
||||||
|
roots.append(node)
|
||||||
|
# 清理 _sa_instance_state
|
||||||
|
def clean(d: dict):
|
||||||
|
d.pop("_sa_instance_state", None)
|
||||||
|
for c in d.get("children", []):
|
||||||
|
clean(c)
|
||||||
|
for r in roots:
|
||||||
|
clean(r)
|
||||||
|
return ResponseModel(message="获取岗位树成功", data=roots)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_position(
|
||||||
|
payload: dict, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db)
|
||||||
|
) -> ResponseModel:
|
||||||
|
obj = Position(
|
||||||
|
name=payload.get("name"),
|
||||||
|
code=payload.get("code"),
|
||||||
|
description=payload.get("description"),
|
||||||
|
parent_id=payload.get("parentId"),
|
||||||
|
status=payload.get("status", "active"),
|
||||||
|
skills=payload.get("skills"),
|
||||||
|
level=payload.get("level"),
|
||||||
|
sort_order=payload.get("sort_order", 0),
|
||||||
|
created_by=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(obj)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(obj)
|
||||||
|
return ResponseModel(message="创建岗位成功", data={"id": obj.id})
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{position_id}")
|
||||||
|
async def update_position(
|
||||||
|
position_id: int, payload: dict, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db)
|
||||||
|
) -> ResponseModel:
|
||||||
|
obj = await db.get(Position, position_id)
|
||||||
|
if not obj or obj.is_deleted:
|
||||||
|
return ResponseModel(code=404, message="岗位不存在")
|
||||||
|
obj.name = payload.get("name", obj.name)
|
||||||
|
obj.code = payload.get("code", obj.code)
|
||||||
|
obj.description = payload.get("description", obj.description)
|
||||||
|
obj.parent_id = payload.get("parentId", obj.parent_id)
|
||||||
|
obj.status = payload.get("status", obj.status)
|
||||||
|
obj.skills = payload.get("skills", obj.skills)
|
||||||
|
obj.level = payload.get("level", obj.level)
|
||||||
|
obj.sort_order = payload.get("sort_order", obj.sort_order)
|
||||||
|
obj.updated_by = current_user.id
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(obj)
|
||||||
|
|
||||||
|
# 返回更新后的完整数据
|
||||||
|
data = obj.__dict__.copy()
|
||||||
|
data.pop("_sa_instance_state", None)
|
||||||
|
return ResponseModel(message="更新岗位成功", data=data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{position_id}")
|
||||||
|
async def get_position_detail(
|
||||||
|
position_id: int, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db)
|
||||||
|
) -> ResponseModel:
|
||||||
|
obj = await db.get(Position, position_id)
|
||||||
|
if not obj or obj.is_deleted:
|
||||||
|
return ResponseModel(code=404, message="岗位不存在")
|
||||||
|
data = obj.__dict__.copy()
|
||||||
|
data.pop("_sa_instance_state", None)
|
||||||
|
return ResponseModel(data=data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{position_id}/check-delete")
|
||||||
|
async def check_position_delete(
|
||||||
|
position_id: int, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db)
|
||||||
|
) -> ResponseModel:
|
||||||
|
obj = await db.get(Position, position_id)
|
||||||
|
if not obj or obj.is_deleted:
|
||||||
|
return ResponseModel(code=404, message="岗位不存在")
|
||||||
|
|
||||||
|
# 检查是否有子岗位
|
||||||
|
child_count_result = await db.execute(
|
||||||
|
select(func.count(Position.id)).where(
|
||||||
|
and_(
|
||||||
|
Position.parent_id == position_id,
|
||||||
|
Position.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
child_count = child_count_result.scalar() or 0
|
||||||
|
|
||||||
|
if child_count > 0:
|
||||||
|
return ResponseModel(data={
|
||||||
|
"deletable": False,
|
||||||
|
"reason": f"该岗位下有 {child_count} 个子岗位,请先删除或移动子岗位"
|
||||||
|
})
|
||||||
|
|
||||||
|
# 检查是否有成员(仅作为提醒,不阻止删除)
|
||||||
|
member_count_result = await db.execute(
|
||||||
|
select(func.count(PositionMember.id)).where(
|
||||||
|
and_(
|
||||||
|
PositionMember.position_id == position_id,
|
||||||
|
PositionMember.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
member_count = member_count_result.scalar() or 0
|
||||||
|
|
||||||
|
warning = ""
|
||||||
|
if member_count > 0:
|
||||||
|
warning = f"注意:该岗位当前有 {member_count} 名成员,删除后这些成员将不再属于此岗位"
|
||||||
|
|
||||||
|
return ResponseModel(data={"deletable": True, "reason": "", "warning": warning, "member_count": member_count})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{position_id}")
|
||||||
|
async def delete_position(
|
||||||
|
position_id: int, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db)
|
||||||
|
) -> ResponseModel:
|
||||||
|
obj = await db.get(Position, position_id)
|
||||||
|
if not obj or obj.is_deleted:
|
||||||
|
return ResponseModel(code=404, message="岗位不存在")
|
||||||
|
|
||||||
|
# 检查是否有子岗位
|
||||||
|
child_count_result = await db.execute(
|
||||||
|
select(func.count(Position.id)).where(
|
||||||
|
and_(
|
||||||
|
Position.parent_id == position_id,
|
||||||
|
Position.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
child_count = child_count_result.scalar() or 0
|
||||||
|
|
||||||
|
if child_count > 0:
|
||||||
|
return ResponseModel(
|
||||||
|
code=400,
|
||||||
|
message=f"该岗位下有 {child_count} 个子岗位,请先删除或移动子岗位"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 软删除岗位成员关联
|
||||||
|
await db.execute(
|
||||||
|
sa.update(PositionMember)
|
||||||
|
.where(PositionMember.position_id == position_id)
|
||||||
|
.values(is_deleted=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 软删除岗位课程关联
|
||||||
|
await db.execute(
|
||||||
|
sa.update(PositionCourse)
|
||||||
|
.where(PositionCourse.position_id == position_id)
|
||||||
|
.values(is_deleted=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 软删除岗位
|
||||||
|
obj.is_deleted = True
|
||||||
|
await db.commit()
|
||||||
|
return ResponseModel(message="岗位已删除")
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 岗位成员管理 API ==========
|
||||||
|
|
||||||
|
@router.get("/{position_id}/members")
|
||||||
|
async def get_position_members(
|
||||||
|
position_id: int,
|
||||||
|
pagination: PaginationParams = Depends(),
|
||||||
|
keyword: Optional[str] = Query(None, description="搜索关键词"),
|
||||||
|
current_user=Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""获取岗位成员列表"""
|
||||||
|
# 验证岗位存在
|
||||||
|
position = await db.get(Position, position_id)
|
||||||
|
if not position or position.is_deleted:
|
||||||
|
return ResponseModel(code=404, message="岗位不存在")
|
||||||
|
|
||||||
|
# 构建查询
|
||||||
|
stmt = (
|
||||||
|
select(PositionMember, User)
|
||||||
|
.join(User, PositionMember.user_id == User.id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
PositionMember.position_id == position_id,
|
||||||
|
PositionMember.is_deleted == False,
|
||||||
|
User.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关键词搜索
|
||||||
|
if keyword:
|
||||||
|
like = f"%{keyword}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
(User.username.ilike(like)) |
|
||||||
|
(User.full_name.ilike(like)) |
|
||||||
|
(User.email.ilike(like))
|
||||||
|
)
|
||||||
|
|
||||||
|
# 执行查询
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rows = result.all()
|
||||||
|
total = len(rows)
|
||||||
|
sliced = rows[pagination.offset : pagination.offset + pagination.limit]
|
||||||
|
|
||||||
|
# 格式化数据
|
||||||
|
items = []
|
||||||
|
for pm, user in sliced:
|
||||||
|
items.append({
|
||||||
|
"id": pm.id,
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"email": user.email,
|
||||||
|
"phone": user.phone,
|
||||||
|
"role": pm.role,
|
||||||
|
"joined_at": pm.joined_at.isoformat() if pm.joined_at else None,
|
||||||
|
"user_role": user.role, # 系统角色
|
||||||
|
"is_active": user.is_active,
|
||||||
|
})
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
message="获取成员列表成功",
|
||||||
|
data={
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"page": pagination.page,
|
||||||
|
"page_size": pagination.page_size,
|
||||||
|
"pages": (total + pagination.page_size - 1) // pagination.page_size if pagination.page_size else 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{position_id}/members")
|
||||||
|
async def add_position_members(
|
||||||
|
position_id: int,
|
||||||
|
payload: dict,
|
||||||
|
current_user=Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""批量添加岗位成员"""
|
||||||
|
# 验证岗位存在
|
||||||
|
position = await db.get(Position, position_id)
|
||||||
|
if not position or position.is_deleted:
|
||||||
|
return ResponseModel(code=404, message="岗位不存在")
|
||||||
|
|
||||||
|
user_ids = payload.get("user_ids", [])
|
||||||
|
if not user_ids:
|
||||||
|
return ResponseModel(code=400, message="请选择要添加的用户")
|
||||||
|
|
||||||
|
# 验证用户存在
|
||||||
|
users = await db.execute(
|
||||||
|
select(User).where(
|
||||||
|
and_(
|
||||||
|
User.id.in_(user_ids),
|
||||||
|
User.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
valid_users = {u.id: u for u in users.scalars().all()}
|
||||||
|
|
||||||
|
if len(valid_users) != len(user_ids):
|
||||||
|
invalid_ids = set(user_ids) - set(valid_users.keys())
|
||||||
|
return ResponseModel(code=400, message=f"部分用户不存在: {invalid_ids}")
|
||||||
|
|
||||||
|
# 检查是否已存在
|
||||||
|
existing = await db.execute(
|
||||||
|
select(PositionMember).where(
|
||||||
|
and_(
|
||||||
|
PositionMember.position_id == position_id,
|
||||||
|
PositionMember.user_id.in_(user_ids),
|
||||||
|
PositionMember.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_user_ids = {pm.user_id for pm in existing.scalars().all()}
|
||||||
|
|
||||||
|
# 添加新成员
|
||||||
|
added_count = 0
|
||||||
|
for user_id in user_ids:
|
||||||
|
if user_id not in existing_user_ids:
|
||||||
|
member = PositionMember(
|
||||||
|
position_id=position_id,
|
||||||
|
user_id=user_id,
|
||||||
|
role=payload.get("role")
|
||||||
|
)
|
||||||
|
db.add(member)
|
||||||
|
added_count += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
message=f"成功添加 {added_count} 个成员",
|
||||||
|
data={"added_count": added_count}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{position_id}/members/{user_id}")
|
||||||
|
async def remove_position_member(
|
||||||
|
position_id: int,
|
||||||
|
user_id: int,
|
||||||
|
current_user=Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""移除岗位成员"""
|
||||||
|
# 查找成员关系
|
||||||
|
member = await db.execute(
|
||||||
|
select(PositionMember).where(
|
||||||
|
and_(
|
||||||
|
PositionMember.position_id == position_id,
|
||||||
|
PositionMember.user_id == user_id,
|
||||||
|
PositionMember.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
member = member.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not member:
|
||||||
|
return ResponseModel(code=404, message="成员关系不存在")
|
||||||
|
|
||||||
|
# 软删除
|
||||||
|
member.is_deleted = True
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return ResponseModel(message="成员已移除")
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 岗位课程管理 API ==========
|
||||||
|
|
||||||
|
@router.get("/{position_id}/courses")
|
||||||
|
async def get_position_courses(
|
||||||
|
position_id: int,
|
||||||
|
course_type: Optional[str] = Query(None, description="课程类型:required/optional"),
|
||||||
|
current_user=Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""获取岗位课程列表"""
|
||||||
|
# 验证岗位存在
|
||||||
|
position = await db.get(Position, position_id)
|
||||||
|
if not position or position.is_deleted:
|
||||||
|
return ResponseModel(code=404, message="岗位不存在")
|
||||||
|
|
||||||
|
# 构建查询
|
||||||
|
stmt = (
|
||||||
|
select(PositionCourse, Course)
|
||||||
|
.join(Course, PositionCourse.course_id == Course.id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
PositionCourse.position_id == position_id,
|
||||||
|
PositionCourse.is_deleted == False,
|
||||||
|
Course.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 课程类型筛选
|
||||||
|
if course_type:
|
||||||
|
stmt = stmt.where(PositionCourse.course_type == course_type)
|
||||||
|
|
||||||
|
# 按优先级排序
|
||||||
|
stmt = stmt.order_by(PositionCourse.priority, PositionCourse.id)
|
||||||
|
|
||||||
|
# 执行查询
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
# 格式化数据
|
||||||
|
items = []
|
||||||
|
for pc, course in rows:
|
||||||
|
items.append({
|
||||||
|
"id": pc.id,
|
||||||
|
"course_id": course.id,
|
||||||
|
"course_name": course.name,
|
||||||
|
"course_description": course.description,
|
||||||
|
"course_category": course.category,
|
||||||
|
"course_status": course.status,
|
||||||
|
"course_duration_hours": course.duration_hours,
|
||||||
|
"course_difficulty_level": course.difficulty_level,
|
||||||
|
"course_type": pc.course_type,
|
||||||
|
"priority": pc.priority,
|
||||||
|
"created_at": pc.created_at.isoformat() if pc.created_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 统计
|
||||||
|
stats = {
|
||||||
|
"total": len(items),
|
||||||
|
"required_count": sum(1 for item in items if item["course_type"] == "required"),
|
||||||
|
"optional_count": sum(1 for item in items if item["course_type"] == "optional"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
message="获取课程列表成功",
|
||||||
|
data={
|
||||||
|
"items": items,
|
||||||
|
"stats": stats
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{position_id}/courses")
|
||||||
|
async def add_position_courses(
|
||||||
|
position_id: int,
|
||||||
|
payload: dict,
|
||||||
|
current_user=Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""批量添加岗位课程"""
|
||||||
|
# 验证岗位存在
|
||||||
|
position = await db.get(Position, position_id)
|
||||||
|
if not position or position.is_deleted:
|
||||||
|
return ResponseModel(code=404, message="岗位不存在")
|
||||||
|
|
||||||
|
course_ids = payload.get("course_ids", [])
|
||||||
|
if not course_ids:
|
||||||
|
return ResponseModel(code=400, message="请选择要添加的课程")
|
||||||
|
|
||||||
|
course_type = payload.get("course_type", "required")
|
||||||
|
if course_type not in ["required", "optional"]:
|
||||||
|
return ResponseModel(code=400, message="课程类型无效")
|
||||||
|
|
||||||
|
# 验证课程存在
|
||||||
|
courses = await db.execute(
|
||||||
|
select(Course).where(
|
||||||
|
and_(
|
||||||
|
Course.id.in_(course_ids),
|
||||||
|
Course.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
valid_courses = {c.id: c for c in courses.scalars().all()}
|
||||||
|
|
||||||
|
if len(valid_courses) != len(course_ids):
|
||||||
|
invalid_ids = set(course_ids) - set(valid_courses.keys())
|
||||||
|
return ResponseModel(code=400, message=f"部分课程不存在: {invalid_ids}")
|
||||||
|
|
||||||
|
# 检查是否已存在
|
||||||
|
existing = await db.execute(
|
||||||
|
select(PositionCourse).where(
|
||||||
|
and_(
|
||||||
|
PositionCourse.position_id == position_id,
|
||||||
|
PositionCourse.course_id.in_(course_ids),
|
||||||
|
PositionCourse.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_course_ids = {pc.course_id for pc in existing.scalars().all()}
|
||||||
|
|
||||||
|
# 获取当前最大优先级
|
||||||
|
max_priority_result = await db.execute(
|
||||||
|
select(sa.func.max(PositionCourse.priority)).where(
|
||||||
|
and_(
|
||||||
|
PositionCourse.position_id == position_id,
|
||||||
|
PositionCourse.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
max_priority = max_priority_result.scalar() or 0
|
||||||
|
|
||||||
|
# 添加新课程
|
||||||
|
added_count = 0
|
||||||
|
for idx, course_id in enumerate(course_ids):
|
||||||
|
if course_id not in existing_course_ids:
|
||||||
|
pc = PositionCourse(
|
||||||
|
position_id=position_id,
|
||||||
|
course_id=course_id,
|
||||||
|
course_type=course_type,
|
||||||
|
priority=max_priority + idx + 1,
|
||||||
|
)
|
||||||
|
db.add(pc)
|
||||||
|
added_count += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
message=f"成功添加 {added_count} 门课程",
|
||||||
|
data={"added_count": added_count}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{position_id}/courses/{pc_id}")
|
||||||
|
async def update_position_course(
|
||||||
|
position_id: int,
|
||||||
|
pc_id: int,
|
||||||
|
payload: dict,
|
||||||
|
current_user=Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""更新岗位课程设置"""
|
||||||
|
# 查找课程关系
|
||||||
|
pc = await db.execute(
|
||||||
|
select(PositionCourse).where(
|
||||||
|
and_(
|
||||||
|
PositionCourse.id == pc_id,
|
||||||
|
PositionCourse.position_id == position_id,
|
||||||
|
PositionCourse.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pc = pc.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not pc:
|
||||||
|
return ResponseModel(code=404, message="课程关系不存在")
|
||||||
|
|
||||||
|
# 更新课程类型
|
||||||
|
if "course_type" in payload:
|
||||||
|
course_type = payload["course_type"]
|
||||||
|
if course_type not in ["required", "optional"]:
|
||||||
|
return ResponseModel(code=400, message="课程类型无效")
|
||||||
|
pc.course_type = course_type
|
||||||
|
|
||||||
|
# 更新优先级
|
||||||
|
if "priority" in payload:
|
||||||
|
pc.priority = payload["priority"]
|
||||||
|
|
||||||
|
# PositionCourse 未继承审计字段,避免写入不存在字段
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return ResponseModel(message="更新成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{position_id}/courses/{course_id}")
|
||||||
|
async def remove_position_course(
|
||||||
|
position_id: int,
|
||||||
|
course_id: int,
|
||||||
|
current_user=Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""移除岗位课程"""
|
||||||
|
# 查找课程关系
|
||||||
|
pc = await db.execute(
|
||||||
|
select(PositionCourse).where(
|
||||||
|
and_(
|
||||||
|
PositionCourse.position_id == position_id,
|
||||||
|
PositionCourse.course_id == course_id,
|
||||||
|
PositionCourse.is_deleted == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pc = pc.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not pc:
|
||||||
|
return ResponseModel(code=404, message="课程关系不存在")
|
||||||
|
|
||||||
|
# 软删除
|
||||||
|
pc.is_deleted = True
|
||||||
|
# PositionCourse 未继承审计字段,避免写入不存在字段
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return ResponseModel(message="课程已移除")
|
||||||
|
|
||||||
|
|
||||||
1139
backend/app/api/v1/practice.py
Normal file
1139
backend/app/api/v1/practice.py
Normal file
File diff suppressed because it is too large
Load Diff
285
backend/app/api/v1/preview.py
Normal file
285
backend/app/api/v1/preview.py
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
"""
|
||||||
|
文件预览API
|
||||||
|
提供课程资料的在线预览功能
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.deps import get_db, get_current_user
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.course import CourseMaterial
|
||||||
|
from app.services.document_converter import document_converter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewType:
|
||||||
|
"""预览类型常量
|
||||||
|
支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties
|
||||||
|
"""
|
||||||
|
PDF = "pdf"
|
||||||
|
TEXT = "text"
|
||||||
|
HTML = "html"
|
||||||
|
EXCEL_HTML = "excel_html" # Excel转HTML预览
|
||||||
|
VIDEO = "video"
|
||||||
|
AUDIO = "audio"
|
||||||
|
IMAGE = "image"
|
||||||
|
DOWNLOAD = "download"
|
||||||
|
|
||||||
|
|
||||||
|
# 文件类型到预览类型的映射
|
||||||
|
FILE_TYPE_MAPPING = {
|
||||||
|
# PDF - 直接预览
|
||||||
|
'.pdf': PreviewType.PDF,
|
||||||
|
|
||||||
|
# 文本 - 直接显示内容
|
||||||
|
'.txt': PreviewType.TEXT,
|
||||||
|
'.md': PreviewType.TEXT,
|
||||||
|
'.mdx': PreviewType.TEXT,
|
||||||
|
'.csv': PreviewType.TEXT,
|
||||||
|
'.vtt': PreviewType.TEXT,
|
||||||
|
'.properties': PreviewType.TEXT,
|
||||||
|
|
||||||
|
# HTML - 在iframe中预览
|
||||||
|
'.html': PreviewType.HTML,
|
||||||
|
'.htm': PreviewType.HTML,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_preview_type(file_ext: str) -> str:
|
||||||
|
"""
|
||||||
|
根据文件扩展名获取预览类型
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_ext: 文件扩展名(带点,如 .pdf)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
预览类型
|
||||||
|
"""
|
||||||
|
file_ext_lower = file_ext.lower()
|
||||||
|
|
||||||
|
# 直接映射的类型
|
||||||
|
if file_ext_lower in FILE_TYPE_MAPPING:
|
||||||
|
return FILE_TYPE_MAPPING[file_ext_lower]
|
||||||
|
|
||||||
|
# Excel文件使用HTML预览(避免分页问题)
|
||||||
|
if file_ext_lower in {'.xlsx', '.xls'}:
|
||||||
|
return PreviewType.EXCEL_HTML
|
||||||
|
|
||||||
|
# 其他Office文档,需要转换为PDF预览
|
||||||
|
if document_converter.is_convertible(file_ext_lower):
|
||||||
|
return PreviewType.PDF
|
||||||
|
|
||||||
|
# 其他类型,只提供下载
|
||||||
|
return PreviewType.DOWNLOAD
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_path_from_url(file_url: str) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
从文件URL获取本地文件路径
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_url: 文件URL(如 /static/uploads/courses/1/xxx.pdf)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
本地文件路径,如果无效返回None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 移除 /static/uploads/ 前缀
|
||||||
|
if file_url.startswith('/static/uploads/'):
|
||||||
|
relative_path = file_url.replace('/static/uploads/', '')
|
||||||
|
full_path = Path(settings.UPLOAD_PATH) / relative_path
|
||||||
|
return full_path
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/material/{material_id}", response_model=ResponseModel[dict])
|
||||||
|
async def get_material_preview(
|
||||||
|
material_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取资料预览信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
material_id: 资料ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
预览信息,包括预览类型、预览URL等
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 查询资料信息
|
||||||
|
stmt = select(CourseMaterial).where(
|
||||||
|
CourseMaterial.id == material_id,
|
||||||
|
CourseMaterial.is_deleted == False
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
material = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not material:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="资料不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: 权限检查 - 确认当前用户是否有权访问该课程的资料
|
||||||
|
# 可以通过查询 position_courses 表和用户的岗位关系来判断
|
||||||
|
|
||||||
|
# 获取文件扩展名
|
||||||
|
file_ext = Path(material.name).suffix.lower()
|
||||||
|
|
||||||
|
# 确定预览类型
|
||||||
|
preview_type = get_preview_type(file_ext)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"资料预览请求 - material_id: {material_id}, "
|
||||||
|
f"file_type: {file_ext}, preview_type: {preview_type}, "
|
||||||
|
f"user_id: {current_user.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建响应数据
|
||||||
|
response_data = {
|
||||||
|
"preview_type": preview_type,
|
||||||
|
"file_name": material.name,
|
||||||
|
"original_url": material.file_url,
|
||||||
|
"file_size": material.file_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 根据预览类型处理
|
||||||
|
if preview_type == PreviewType.TEXT:
|
||||||
|
# 文本类型,读取文件内容
|
||||||
|
file_path = get_file_path_from_url(material.file_url)
|
||||||
|
if file_path and file_path.exists():
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
response_data["content"] = content
|
||||||
|
response_data["preview_url"] = None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"读取文本文件失败: {str(e)}")
|
||||||
|
# 读取失败,改为下载模式
|
||||||
|
response_data["preview_type"] = PreviewType.DOWNLOAD
|
||||||
|
response_data["preview_url"] = material.file_url
|
||||||
|
else:
|
||||||
|
response_data["preview_type"] = PreviewType.DOWNLOAD
|
||||||
|
response_data["preview_url"] = material.file_url
|
||||||
|
|
||||||
|
elif preview_type == PreviewType.EXCEL_HTML:
|
||||||
|
# Excel文件转换为HTML预览
|
||||||
|
file_path = get_file_path_from_url(material.file_url)
|
||||||
|
if file_path and file_path.exists():
|
||||||
|
converted_url = document_converter.convert_excel_to_html(
|
||||||
|
str(file_path),
|
||||||
|
material.course_id,
|
||||||
|
material.id
|
||||||
|
)
|
||||||
|
if converted_url:
|
||||||
|
response_data["preview_url"] = converted_url
|
||||||
|
response_data["preview_type"] = "html" # 前端使用html类型渲染
|
||||||
|
response_data["is_converted"] = True
|
||||||
|
else:
|
||||||
|
logger.warning(f"Excel转HTML失败,改为下载模式 - material_id: {material_id}")
|
||||||
|
response_data["preview_type"] = PreviewType.DOWNLOAD
|
||||||
|
response_data["preview_url"] = material.file_url
|
||||||
|
response_data["is_converted"] = False
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="文件不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif preview_type == PreviewType.PDF and document_converter.is_convertible(file_ext):
|
||||||
|
# Office文档,需要转换为PDF
|
||||||
|
file_path = get_file_path_from_url(material.file_url)
|
||||||
|
if file_path and file_path.exists():
|
||||||
|
# 执行转换
|
||||||
|
converted_url = document_converter.convert_to_pdf(
|
||||||
|
str(file_path),
|
||||||
|
material.course_id,
|
||||||
|
material.id
|
||||||
|
)
|
||||||
|
if converted_url:
|
||||||
|
response_data["preview_url"] = converted_url
|
||||||
|
response_data["is_converted"] = True
|
||||||
|
else:
|
||||||
|
# 转换失败,改为下载模式
|
||||||
|
logger.warning(f"文档转换失败,改为下载模式 - material_id: {material_id}")
|
||||||
|
response_data["preview_type"] = PreviewType.DOWNLOAD
|
||||||
|
response_data["preview_url"] = material.file_url
|
||||||
|
response_data["is_converted"] = False
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="文件不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 其他类型,直接返回原始URL
|
||||||
|
response_data["preview_url"] = material.file_url
|
||||||
|
|
||||||
|
return ResponseModel(data=response_data, message="获取预览信息成功")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取资料预览信息失败: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="获取预览信息失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/check-converter", response_model=ResponseModel[dict])
|
||||||
|
async def check_converter_status(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
检查文档转换服务状态(用于调试)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
转换服务状态信息
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# 检查 LibreOffice 是否安装
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['libreoffice', '--version'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
libreoffice_installed = result.returncode == 0
|
||||||
|
libreoffice_version = result.stdout.strip() if libreoffice_installed else None
|
||||||
|
except Exception:
|
||||||
|
libreoffice_installed = False
|
||||||
|
libreoffice_version = None
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
data={
|
||||||
|
"libreoffice_installed": libreoffice_installed,
|
||||||
|
"libreoffice_version": libreoffice_version,
|
||||||
|
"supported_formats": list(document_converter.SUPPORTED_FORMATS),
|
||||||
|
"converted_path": str(document_converter.converted_path),
|
||||||
|
},
|
||||||
|
message="转换服务状态检查完成"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"检查转换服务状态失败: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="检查转换服务状态失败"
|
||||||
|
)
|
||||||
|
|
||||||
311
backend/app/api/v1/scrm.py
Normal file
311
backend/app/api/v1/scrm.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
"""
|
||||||
|
SCRM 系统对接 API 路由
|
||||||
|
|
||||||
|
提供给 SCRM 系统调用的数据查询接口
|
||||||
|
认证方式:Bearer Token (SCRM_API_KEY)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_db, verify_scrm_api_key
|
||||||
|
from app.services.scrm_service import SCRMService
|
||||||
|
from app.schemas.scrm import (
|
||||||
|
EmployeePositionResponse,
|
||||||
|
EmployeePositionData,
|
||||||
|
PositionCoursesResponse,
|
||||||
|
PositionCoursesData,
|
||||||
|
KnowledgePointSearchRequest,
|
||||||
|
KnowledgePointSearchResponse,
|
||||||
|
KnowledgePointSearchData,
|
||||||
|
KnowledgePointDetailResponse,
|
||||||
|
KnowledgePointDetailData,
|
||||||
|
SCRMErrorResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/scrm", tags=["scrm"])
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 1. 获取员工岗位 ====================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/employees/{userid}/position",
|
||||||
|
response_model=EmployeePositionResponse,
|
||||||
|
summary="获取员工岗位(通过userid)",
|
||||||
|
description="根据企微 userid 查询员工在考陪练系统中的岗位信息",
|
||||||
|
responses={
|
||||||
|
200: {"model": EmployeePositionResponse, "description": "成功"},
|
||||||
|
401: {"model": SCRMErrorResponse, "description": "认证失败"},
|
||||||
|
404: {"model": SCRMErrorResponse, "description": "员工不存在"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def get_employee_position_by_userid(
|
||||||
|
userid: str,
|
||||||
|
_: bool = Depends(verify_scrm_api_key),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取员工岗位(通过企微userid)
|
||||||
|
|
||||||
|
- **userid**: 企微员工 userid
|
||||||
|
"""
|
||||||
|
service = SCRMService(db)
|
||||||
|
result = await service.get_employee_position(userid=userid)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={
|
||||||
|
"code": 404,
|
||||||
|
"message": "员工不存在",
|
||||||
|
"data": None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查是否有多个匹配结果
|
||||||
|
if result.get("multiple_matches"):
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": f"找到 {result['count']} 个匹配的员工,请确认",
|
||||||
|
"data": result
|
||||||
|
}
|
||||||
|
|
||||||
|
return EmployeePositionResponse(
|
||||||
|
code=0,
|
||||||
|
message="success",
|
||||||
|
data=EmployeePositionData(**result)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/employees/search/by-name",
|
||||||
|
summary="获取员工岗位(通过姓名搜索)",
|
||||||
|
description="根据员工姓名查询员工在考陪练系统中的岗位信息,支持精确匹配和模糊匹配",
|
||||||
|
responses={
|
||||||
|
200: {"description": "成功"},
|
||||||
|
401: {"model": SCRMErrorResponse, "description": "认证失败"},
|
||||||
|
404: {"model": SCRMErrorResponse, "description": "员工不存在"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def get_employee_position_by_name(
|
||||||
|
name: str = Query(..., description="员工姓名,支持精确匹配和模糊匹配"),
|
||||||
|
_: bool = Depends(verify_scrm_api_key),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取员工岗位(通过姓名搜索)
|
||||||
|
|
||||||
|
- **name**: 员工姓名(必填),优先精确匹配,无结果时模糊匹配
|
||||||
|
|
||||||
|
注意:如果有多个同名员工,会返回员工列表供确认
|
||||||
|
"""
|
||||||
|
service = SCRMService(db)
|
||||||
|
result = await service.get_employee_position(name=name)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={
|
||||||
|
"code": 404,
|
||||||
|
"message": f"未找到姓名包含 '{name}' 的员工",
|
||||||
|
"data": None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查是否有多个匹配结果
|
||||||
|
if result.get("multiple_matches"):
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": f"找到 {result['count']} 个匹配的员工,请确认后使用 employee_id 精确查询",
|
||||||
|
"data": result
|
||||||
|
}
|
||||||
|
|
||||||
|
return EmployeePositionResponse(
|
||||||
|
code=0,
|
||||||
|
message="success",
|
||||||
|
data=EmployeePositionData(**result)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/employees/by-id/{employee_id}/position",
|
||||||
|
response_model=EmployeePositionResponse,
|
||||||
|
summary="获取员工岗位(通过员工ID)",
|
||||||
|
description="根据员工ID精确查询员工岗位信息,用于多个同名员工时的精确查询",
|
||||||
|
responses={
|
||||||
|
200: {"model": EmployeePositionResponse, "description": "成功"},
|
||||||
|
401: {"model": SCRMErrorResponse, "description": "认证失败"},
|
||||||
|
404: {"model": SCRMErrorResponse, "description": "员工不存在"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def get_employee_position_by_id(
|
||||||
|
employee_id: int,
|
||||||
|
_: bool = Depends(verify_scrm_api_key),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取员工岗位(通过员工ID精确查询)
|
||||||
|
|
||||||
|
- **employee_id**: 员工ID(考陪练系统用户ID)
|
||||||
|
|
||||||
|
适用场景:通过姓名搜索返回多个匹配结果后,使用此接口精确查询
|
||||||
|
"""
|
||||||
|
service = SCRMService(db)
|
||||||
|
result = await service.get_employee_position_by_id(employee_id)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={
|
||||||
|
"code": 404,
|
||||||
|
"message": "员工不存在",
|
||||||
|
"data": None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return EmployeePositionResponse(
|
||||||
|
code=0,
|
||||||
|
message="success",
|
||||||
|
data=EmployeePositionData(**result)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 2. 获取岗位课程列表 ====================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/positions/{position_id}/courses",
|
||||||
|
response_model=PositionCoursesResponse,
|
||||||
|
summary="获取岗位课程列表",
|
||||||
|
description="获取指定岗位的必修/选修课程列表",
|
||||||
|
responses={
|
||||||
|
200: {"model": PositionCoursesResponse, "description": "成功"},
|
||||||
|
401: {"model": SCRMErrorResponse, "description": "认证失败"},
|
||||||
|
404: {"model": SCRMErrorResponse, "description": "岗位不存在"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def get_position_courses(
|
||||||
|
position_id: int,
|
||||||
|
course_type: Optional[str] = Query(
|
||||||
|
default="all",
|
||||||
|
description="课程类型:required/optional/all",
|
||||||
|
regex="^(required|optional|all)$"
|
||||||
|
),
|
||||||
|
_: bool = Depends(verify_scrm_api_key),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取岗位课程列表
|
||||||
|
|
||||||
|
- **position_id**: 岗位ID
|
||||||
|
- **course_type**: 课程类型筛选(required/optional/all,默认 all)
|
||||||
|
"""
|
||||||
|
service = SCRMService(db)
|
||||||
|
result = await service.get_position_courses(position_id, course_type)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={
|
||||||
|
"code": 40002,
|
||||||
|
"message": "position_id 不存在",
|
||||||
|
"data": None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return PositionCoursesResponse(
|
||||||
|
code=0,
|
||||||
|
message="success",
|
||||||
|
data=PositionCoursesData(**result)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 3. 搜索知识点 ====================
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/knowledge-points/search",
|
||||||
|
response_model=KnowledgePointSearchResponse,
|
||||||
|
summary="搜索知识点",
|
||||||
|
description="根据关键词和岗位搜索匹配的知识点",
|
||||||
|
responses={
|
||||||
|
200: {"model": KnowledgePointSearchResponse, "description": "成功"},
|
||||||
|
401: {"model": SCRMErrorResponse, "description": "认证失败"},
|
||||||
|
400: {"model": SCRMErrorResponse, "description": "请求参数错误"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def search_knowledge_points(
|
||||||
|
request: KnowledgePointSearchRequest,
|
||||||
|
_: bool = Depends(verify_scrm_api_key),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
搜索知识点
|
||||||
|
|
||||||
|
- **keywords**: 搜索关键词列表(必填)
|
||||||
|
- **position_id**: 岗位ID(用于优先排序,可选)
|
||||||
|
- **course_ids**: 限定课程范围(可选)
|
||||||
|
- **knowledge_type**: 知识点类型筛选(可选)
|
||||||
|
- **limit**: 返回数量,默认10,最大100
|
||||||
|
"""
|
||||||
|
service = SCRMService(db)
|
||||||
|
result = await service.search_knowledge_points(
|
||||||
|
keywords=request.keywords,
|
||||||
|
position_id=request.position_id,
|
||||||
|
course_ids=request.course_ids,
|
||||||
|
knowledge_type=request.knowledge_type,
|
||||||
|
limit=request.limit
|
||||||
|
)
|
||||||
|
|
||||||
|
return KnowledgePointSearchResponse(
|
||||||
|
code=0,
|
||||||
|
message="success",
|
||||||
|
data=KnowledgePointSearchData(**result)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 4. 获取知识点详情 ====================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/knowledge-points/{knowledge_point_id}",
|
||||||
|
response_model=KnowledgePointDetailResponse,
|
||||||
|
summary="获取知识点详情",
|
||||||
|
description="获取知识点的完整信息",
|
||||||
|
responses={
|
||||||
|
200: {"model": KnowledgePointDetailResponse, "description": "成功"},
|
||||||
|
401: {"model": SCRMErrorResponse, "description": "认证失败"},
|
||||||
|
404: {"model": SCRMErrorResponse, "description": "知识点不存在"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def get_knowledge_point_detail(
|
||||||
|
knowledge_point_id: int,
|
||||||
|
_: bool = Depends(verify_scrm_api_key),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取知识点详情
|
||||||
|
|
||||||
|
- **knowledge_point_id**: 知识点ID
|
||||||
|
"""
|
||||||
|
service = SCRMService(db)
|
||||||
|
result = await service.get_knowledge_point_detail(knowledge_point_id)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={
|
||||||
|
"code": 40003,
|
||||||
|
"message": "knowledge_point_id 不存在",
|
||||||
|
"data": None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return KnowledgePointDetailResponse(
|
||||||
|
code=0,
|
||||||
|
message="success",
|
||||||
|
data=KnowledgePointDetailData(**result)
|
||||||
|
)
|
||||||
|
|
||||||
363
backend/app/api/v1/sql_executor.py
Normal file
363
backend/app/api/v1/sql_executor.py
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
"""
|
||||||
|
SQL 执行器 API - 用于内部服务调用
|
||||||
|
支持执行查询和写入操作的 SQL 语句
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.engine.result import Result
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, get_db
|
||||||
|
try:
|
||||||
|
from app.core.simple_auth import get_current_user_simple
|
||||||
|
except ImportError:
|
||||||
|
get_current_user_simple = None
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["SQL Executor"])
|
||||||
|
|
||||||
|
|
||||||
|
class SQLExecutorRequest:
|
||||||
|
"""SQL执行请求模型"""
|
||||||
|
def __init__(self, sql: str, params: Optional[Dict[str, Any]] = None):
|
||||||
|
self.sql = sql
|
||||||
|
self.params = params or {}
|
||||||
|
|
||||||
|
|
||||||
|
class DateTimeEncoder(json.JSONEncoder):
|
||||||
|
"""处理日期时间对象的 JSON 编码器"""
|
||||||
|
def default(self, obj):
|
||||||
|
if isinstance(obj, (datetime, date)):
|
||||||
|
return obj.isoformat()
|
||||||
|
return super().default(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_row(row: Any) -> Union[Dict[str, Any], Any]:
|
||||||
|
"""序列化数据库行结果"""
|
||||||
|
if hasattr(row, '_mapping'):
|
||||||
|
# 处理 SQLAlchemy Row 对象
|
||||||
|
return dict(row._mapping)
|
||||||
|
elif hasattr(row, '__dict__'):
|
||||||
|
# 处理 ORM 对象
|
||||||
|
return {k: v for k, v in row.__dict__.items() if not k.startswith('_')}
|
||||||
|
else:
|
||||||
|
# 处理单值结果
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/execute", response_model=ResponseModel)
|
||||||
|
async def execute_sql(
|
||||||
|
request: Dict[str, Any],
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
执行 SQL 语句
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: 包含 sql 和可选的 params 字段
|
||||||
|
- sql: SQL 语句
|
||||||
|
- params: 参数字典(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
执行结果,包括:
|
||||||
|
- 查询操作:返回数据行
|
||||||
|
- 写入操作:返回影响的行数
|
||||||
|
|
||||||
|
安全说明:
|
||||||
|
- 需要用户身份验证
|
||||||
|
- 所有操作都会记录日志
|
||||||
|
- 建议在生产环境中限制可执行的 SQL 类型
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 提取参数
|
||||||
|
sql = request.get('sql', '').strip()
|
||||||
|
params = request.get('params', {})
|
||||||
|
|
||||||
|
if not sql:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="SQL 语句不能为空"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录 SQL 执行日志
|
||||||
|
logger.info(
|
||||||
|
"sql_execution_request",
|
||||||
|
user_id=current_user.id,
|
||||||
|
username=current_user.username,
|
||||||
|
sql_type=sql.split()[0].upper() if sql else "UNKNOWN",
|
||||||
|
sql_length=len(sql),
|
||||||
|
has_params=bool(params)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 判断 SQL 类型
|
||||||
|
sql_upper = sql.upper().strip()
|
||||||
|
is_select = sql_upper.startswith('SELECT')
|
||||||
|
is_show = sql_upper.startswith('SHOW')
|
||||||
|
is_describe = sql_upper.startswith(('DESCRIBE', 'DESC'))
|
||||||
|
is_query = is_select or is_show or is_describe
|
||||||
|
|
||||||
|
# 执行 SQL
|
||||||
|
try:
|
||||||
|
result = await db.execute(text(sql), params)
|
||||||
|
|
||||||
|
if is_query:
|
||||||
|
# 查询操作
|
||||||
|
rows = result.fetchall()
|
||||||
|
columns = list(result.keys()) if result.keys() else []
|
||||||
|
|
||||||
|
# 序列化结果
|
||||||
|
data = []
|
||||||
|
for row in rows:
|
||||||
|
serialized_row = serialize_row(row)
|
||||||
|
if isinstance(serialized_row, dict):
|
||||||
|
data.append(serialized_row)
|
||||||
|
else:
|
||||||
|
# 单列结果
|
||||||
|
data.append({columns[0] if columns else 'value': serialized_row})
|
||||||
|
|
||||||
|
# 使用自定义编码器处理日期时间
|
||||||
|
response_data = {
|
||||||
|
"type": "query",
|
||||||
|
"columns": columns,
|
||||||
|
"rows": json.loads(json.dumps(data, cls=DateTimeEncoder)),
|
||||||
|
"row_count": len(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"sql_query_success",
|
||||||
|
user_id=current_user.id,
|
||||||
|
row_count=len(data),
|
||||||
|
column_count=len(columns)
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 写入操作
|
||||||
|
await db.commit()
|
||||||
|
affected_rows = result.rowcount
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
"type": "execute",
|
||||||
|
"affected_rows": affected_rows,
|
||||||
|
"success": True
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"sql_execute_success",
|
||||||
|
user_id=current_user.id,
|
||||||
|
affected_rows=affected_rows
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="SQL 执行成功",
|
||||||
|
data=response_data
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# 回滚事务
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(
|
||||||
|
"sql_execution_error",
|
||||||
|
user_id=current_user.id,
|
||||||
|
sql_type=sql.split()[0].upper() if sql else "UNKNOWN",
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"SQL 执行失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"sql_executor_error",
|
||||||
|
user_id=current_user.id,
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"处理请求时发生错误: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/validate", response_model=ResponseModel)
|
||||||
|
async def validate_sql(
|
||||||
|
request: Dict[str, Any],
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
验证 SQL 语句的语法(不执行)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: 包含 sql 字段的请求
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
验证结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sql = request.get('sql', '').strip()
|
||||||
|
|
||||||
|
if not sql:
|
||||||
|
return ResponseModel(
|
||||||
|
code=400,
|
||||||
|
message="SQL 语句不能为空",
|
||||||
|
data={"valid": False, "error": "SQL 语句不能为空"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 基本的 SQL 验证
|
||||||
|
sql_upper = sql.upper().strip()
|
||||||
|
|
||||||
|
# 检查危险操作(可根据需要调整)
|
||||||
|
dangerous_keywords = ['DROP', 'TRUNCATE', 'DELETE FROM', 'UPDATE']
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
for keyword in dangerous_keywords:
|
||||||
|
if keyword in sql_upper:
|
||||||
|
warnings.append(f"包含危险操作: {keyword}")
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="SQL 验证完成",
|
||||||
|
data={
|
||||||
|
"valid": True,
|
||||||
|
"warnings": warnings,
|
||||||
|
"sql_type": sql_upper.split()[0] if sql_upper else "UNKNOWN"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"sql_validation_error",
|
||||||
|
user_id=current_user.id,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return ResponseModel(
|
||||||
|
code=500,
|
||||||
|
message="SQL 验证失败",
|
||||||
|
data={"valid": False, "error": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tables", response_model=ResponseModel)
|
||||||
|
async def get_tables(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取数据库中的所有表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
数据库表列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await db.execute(text("SHOW TABLES"))
|
||||||
|
tables = [row[0] for row in result.fetchall()]
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取表列表成功",
|
||||||
|
data={
|
||||||
|
"tables": tables,
|
||||||
|
"count": len(tables)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"get_tables_error",
|
||||||
|
user_id=current_user.id,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取表列表失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/table/{table_name}/schema", response_model=ResponseModel)
|
||||||
|
async def get_table_schema(
|
||||||
|
table_name: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取指定表的结构信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
table_name: 表名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
表结构信息
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# MySQL 的 DESCRIBE 不支持参数化,需要直接拼接
|
||||||
|
# 但为了安全,先验证表名
|
||||||
|
if not table_name.replace('_', '').isalnum():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="无效的表名"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(text(f"DESCRIBE {table_name}"))
|
||||||
|
|
||||||
|
columns = []
|
||||||
|
for row in result.fetchall():
|
||||||
|
columns.append({
|
||||||
|
"field": row[0],
|
||||||
|
"type": row[1],
|
||||||
|
"null": row[2],
|
||||||
|
"key": row[3],
|
||||||
|
"default": row[4],
|
||||||
|
"extra": row[5]
|
||||||
|
})
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取表结构成功",
|
||||||
|
data={
|
||||||
|
"table_name": table_name,
|
||||||
|
"columns": columns,
|
||||||
|
"column_count": len(columns)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"get_table_schema_error",
|
||||||
|
user_id=current_user.id,
|
||||||
|
table_name=table_name,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取表结构失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 简化认证版本的端点(如果启用)
|
||||||
|
if get_current_user_simple:
|
||||||
|
@router.post("/execute-simple", response_model=ResponseModel)
|
||||||
|
async def execute_sql_simple(
|
||||||
|
request: Dict[str, Any],
|
||||||
|
current_user: User = Depends(get_current_user_simple),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
执行 SQL 语句(简化认证版本)
|
||||||
|
|
||||||
|
支持 API Key 和 Token 两种认证方式,专为内部服务设计。
|
||||||
|
"""
|
||||||
|
return await execute_sql(request, current_user, db)
|
||||||
5
backend/app/api/v1/sql_executor_simple_auth.py
Normal file
5
backend/app/api/v1/sql_executor_simple_auth.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
SQL 执行器 API - 简化认证版本(已删除,功能已整合到主文件)
|
||||||
|
"""
|
||||||
|
# 此文件的功能已经整合到 sql_executor.py 中
|
||||||
|
# 请使用 /api/v1/sql/execute-simple 端点
|
||||||
238
backend/app/api/v1/statistics.py
Normal file
238
backend/app/api/v1/statistics.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"""
|
||||||
|
统计分析API路由
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_db, get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
from app.services.statistics_service import StatisticsService
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter(prefix="/statistics", tags=["statistics"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/key-metrics", response_model=ResponseModel)
|
||||||
|
async def get_key_metrics(
|
||||||
|
course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"),
|
||||||
|
period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取关键指标
|
||||||
|
|
||||||
|
返回:
|
||||||
|
- learningEfficiency: 学习效率
|
||||||
|
- knowledgeCoverage: 知识覆盖率
|
||||||
|
- avgTimePerQuestion: 平均用时
|
||||||
|
- progressSpeed: 进步速度
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
metrics = await StatisticsService.get_key_metrics(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
course_id=course_id,
|
||||||
|
period=period
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取关键指标成功",
|
||||||
|
data=metrics
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取关键指标失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(
|
||||||
|
code=500,
|
||||||
|
message=f"获取关键指标失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/score-distribution", response_model=ResponseModel)
|
||||||
|
async def get_score_distribution(
|
||||||
|
course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"),
|
||||||
|
period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取成绩分布统计
|
||||||
|
|
||||||
|
返回各分数段的考试数量:
|
||||||
|
- excellent: 优秀(90-100)
|
||||||
|
- good: 良好(80-89)
|
||||||
|
- medium: 中等(70-79)
|
||||||
|
- pass: 及格(60-69)
|
||||||
|
- fail: 不及格(<60)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
distribution = await StatisticsService.get_score_distribution(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
course_id=course_id,
|
||||||
|
period=period
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取成绩分布成功",
|
||||||
|
data=distribution
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取成绩分布失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(
|
||||||
|
code=500,
|
||||||
|
message=f"获取成绩分布失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/difficulty-analysis", response_model=ResponseModel)
|
||||||
|
async def get_difficulty_analysis(
|
||||||
|
course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"),
|
||||||
|
period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取题目难度分析
|
||||||
|
|
||||||
|
返回各难度题目的正确率:
|
||||||
|
- 简单题
|
||||||
|
- 中等题
|
||||||
|
- 困难题
|
||||||
|
- 综合题
|
||||||
|
- 应用题
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
analysis = await StatisticsService.get_difficulty_analysis(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
course_id=course_id,
|
||||||
|
period=period
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取难度分析成功",
|
||||||
|
data=analysis
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取难度分析失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(
|
||||||
|
code=500,
|
||||||
|
message=f"获取难度分析失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge-mastery", response_model=ResponseModel)
|
||||||
|
async def get_knowledge_mastery(
|
||||||
|
course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取知识点掌握度
|
||||||
|
|
||||||
|
返回知识点列表及其掌握度:
|
||||||
|
- name: 知识点名称
|
||||||
|
- mastery: 掌握度(0-100)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
mastery = await StatisticsService.get_knowledge_mastery(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
course_id=course_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取知识点掌握度成功",
|
||||||
|
data=mastery
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取知识点掌握度失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(
|
||||||
|
code=500,
|
||||||
|
message=f"获取知识点掌握度失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/study-time", response_model=ResponseModel)
|
||||||
|
async def get_study_time_stats(
|
||||||
|
course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"),
|
||||||
|
period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取学习时长统计
|
||||||
|
|
||||||
|
返回学习时长和练习时长的日期分布:
|
||||||
|
- labels: 日期标签列表
|
||||||
|
- studyTime: 学习时长列表(小时)
|
||||||
|
- practiceTime: 练习时长列表(小时)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
time_stats = await StatisticsService.get_study_time_stats(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
course_id=course_id,
|
||||||
|
period=period
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取学习时长统计成功",
|
||||||
|
data=time_stats
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取学习时长统计失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(
|
||||||
|
code=500,
|
||||||
|
message=f"获取学习时长统计失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/detail", response_model=ResponseModel)
|
||||||
|
async def get_detail_data(
|
||||||
|
course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"),
|
||||||
|
period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取详细统计数据(按日期)
|
||||||
|
|
||||||
|
返回每日详细统计数据:
|
||||||
|
- date: 日期
|
||||||
|
- examCount: 考试次数
|
||||||
|
- avgScore: 平均分
|
||||||
|
- studyTime: 学习时长(小时)
|
||||||
|
- questionCount: 练习题数
|
||||||
|
- accuracy: 正确率
|
||||||
|
- improvement: 进步指数
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
detail = await StatisticsService.get_detail_data(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
course_id=course_id,
|
||||||
|
period=period
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取详细数据成功",
|
||||||
|
data=detail
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取详细数据失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(
|
||||||
|
code=500,
|
||||||
|
message=f"获取详细数据失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
139
backend/app/api/v1/system.py
Normal file
139
backend/app/api/v1/system.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
系统API - 供外部服务回调使用
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Header
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.core.deps import get_db
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
from app.schemas.course import KnowledgePointCreate
|
||||||
|
from app.services.course_service import knowledge_point_service, course_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/system")
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgePointData(BaseModel):
|
||||||
|
"""知识点数据模型"""
|
||||||
|
name: str = Field(..., description="知识点名称")
|
||||||
|
description: str = Field(default="", description="知识点描述")
|
||||||
|
type: str = Field(default="理论知识", description="知识点类型")
|
||||||
|
source: int = Field(default=1, description="来源:0=手动,1=AI分析")
|
||||||
|
topic_relation: Optional[str] = Field(None, description="与主题的关系描述")
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeCallbackRequest(BaseModel):
|
||||||
|
"""知识点回调请求模型(已弃用,保留向后兼容)"""
|
||||||
|
course_id: int = Field(..., description="课程ID")
|
||||||
|
material_id: int = Field(..., description="资料ID")
|
||||||
|
knowledge_points: List[KnowledgePointData] = Field(..., description="知识点列表")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge", response_model=ResponseModel[Dict[str, Any]])
|
||||||
|
async def create_knowledge_points_callback(
|
||||||
|
request: KnowledgeCallbackRequest,
|
||||||
|
authorization: str = Header(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
创建知识点回调接口(已弃用)
|
||||||
|
|
||||||
|
注意:此接口已弃用,知识点分析现使用 Python 原生实现。
|
||||||
|
保留此接口仅为向后兼容。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# API密钥验证(已弃用的接口,保留向后兼容)
|
||||||
|
expected_token = "Bearer callback-token-2025"
|
||||||
|
if authorization != expected_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="无效的授权令牌"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证课程是否存在
|
||||||
|
course = await course_service.get_by_id(db, request.course_id)
|
||||||
|
if not course:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"课程 {request.course_id} 不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证资料是否存在
|
||||||
|
materials = await course_service.get_course_materials(db, course_id=request.course_id)
|
||||||
|
material = next((m for m in materials if m.id == request.material_id), None)
|
||||||
|
if not material:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"资料 {request.material_id} 不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建知识点
|
||||||
|
created_points = []
|
||||||
|
for kp_data in request.knowledge_points:
|
||||||
|
try:
|
||||||
|
knowledge_point_create = KnowledgePointCreate(
|
||||||
|
name=kp_data.name,
|
||||||
|
description=kp_data.description,
|
||||||
|
type=kp_data.type,
|
||||||
|
source=kp_data.source, # AI分析来源=1
|
||||||
|
topic_relation=kp_data.topic_relation,
|
||||||
|
material_id=request.material_id # 关联资料ID
|
||||||
|
)
|
||||||
|
|
||||||
|
# 使用系统用户ID (假设为1,或者可以配置)
|
||||||
|
system_user_id = 1
|
||||||
|
knowledge_point = await knowledge_point_service.create_knowledge_point(
|
||||||
|
db=db,
|
||||||
|
course_id=request.course_id,
|
||||||
|
point_in=knowledge_point_create,
|
||||||
|
created_by=system_user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
created_points.append({
|
||||||
|
"id": knowledge_point.id,
|
||||||
|
"name": knowledge_point.name,
|
||||||
|
"description": knowledge_point.description,
|
||||||
|
"type": knowledge_point.type,
|
||||||
|
"source": knowledge_point.source,
|
||||||
|
"material_id": knowledge_point.material_id
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"创建知识点失败 - name: {kp_data.name}, error: {str(e)}"
|
||||||
|
)
|
||||||
|
# 继续处理其他知识点,不因为单个失败而中断
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"知识点回调成功 - course_id: {request.course_id}, material_id: {request.material_id}, created_points: {len(created_points)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
data={
|
||||||
|
"course_id": request.course_id,
|
||||||
|
"material_id": request.material_id,
|
||||||
|
"knowledge_points_count": len(created_points),
|
||||||
|
"knowledge_points": created_points
|
||||||
|
},
|
||||||
|
message=f"成功创建 {len(created_points)} 个知识点"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"知识点回调处理失败 - course_id: {request.course_id}, material_id: {request.material_id}, error: {str(e)}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="知识点创建失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
184
backend/app/api/v1/system_logs.py
Normal file
184
backend/app/api/v1/system_logs.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"""
|
||||||
|
系统日志 API
|
||||||
|
提供日志查询、筛选、详情查看等功能
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_db, get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
from app.schemas.system_log import (
|
||||||
|
SystemLogCreate,
|
||||||
|
SystemLogResponse,
|
||||||
|
SystemLogQuery,
|
||||||
|
SystemLogListResponse
|
||||||
|
)
|
||||||
|
from app.services.system_log_service import system_log_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/logs")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ResponseModel[SystemLogListResponse])
|
||||||
|
async def get_system_logs(
|
||||||
|
level: Optional[str] = Query(None, description="日志级别筛选"),
|
||||||
|
type: Optional[str] = Query(None, description="日志类型筛选"),
|
||||||
|
user: Optional[str] = Query(None, description="用户筛选"),
|
||||||
|
keyword: Optional[str] = Query(None, description="关键词搜索"),
|
||||||
|
start_date: Optional[datetime] = Query(None, description="开始日期"),
|
||||||
|
end_date: Optional[datetime] = Query(None, description="结束日期"),
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取系统日志列表
|
||||||
|
支持按级别、类型、用户、关键词、日期范围筛选
|
||||||
|
仅管理员可访问
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 权限检查:仅管理员可查看系统日志
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="无权限访问系统日志")
|
||||||
|
|
||||||
|
# 构建查询参数
|
||||||
|
query_params = SystemLogQuery(
|
||||||
|
level=level,
|
||||||
|
type=type,
|
||||||
|
user=user,
|
||||||
|
keyword=keyword,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
# 查询日志
|
||||||
|
logs, total = await system_log_service.get_logs(db, query_params)
|
||||||
|
|
||||||
|
# 计算总页数
|
||||||
|
total_pages = (total + page_size - 1) // page_size
|
||||||
|
|
||||||
|
# 转换为响应格式
|
||||||
|
log_responses = [SystemLogResponse.model_validate(log) for log in logs]
|
||||||
|
|
||||||
|
response_data = SystemLogListResponse(
|
||||||
|
items=log_responses,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
total_pages=total_pages
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取系统日志成功",
|
||||||
|
data=response_data
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取系统日志失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取系统日志失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{log_id}", response_model=ResponseModel[SystemLogResponse])
|
||||||
|
async def get_log_detail(
|
||||||
|
log_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取日志详情
|
||||||
|
仅管理员可访问
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 权限检查
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="无权限访问系统日志")
|
||||||
|
|
||||||
|
# 查询日志
|
||||||
|
log = await system_log_service.get_log_by_id(db, log_id)
|
||||||
|
|
||||||
|
if not log:
|
||||||
|
raise HTTPException(status_code=404, detail="日志不存在")
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取日志详情成功",
|
||||||
|
data=SystemLogResponse.model_validate(log)
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取日志详情失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取日志详情失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ResponseModel[SystemLogResponse])
|
||||||
|
async def create_system_log(
|
||||||
|
log_data: SystemLogCreate,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
创建系统日志(内部API,供系统各模块调用)
|
||||||
|
注意:此接口不需要用户认证,但应该只供内部调用
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
log = await system_log_service.create_log(db, log_data)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="创建日志成功",
|
||||||
|
data=SystemLogResponse.model_validate(log)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建日志失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"创建日志失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/cleanup")
|
||||||
|
async def cleanup_old_logs(
|
||||||
|
before_days: int = Query(90, ge=1, description="删除多少天之前的日志"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
清理旧日志
|
||||||
|
仅管理员可访问
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 权限检查
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="无权限执行此操作")
|
||||||
|
|
||||||
|
# 计算截止日期
|
||||||
|
from datetime import timedelta
|
||||||
|
before_date = datetime.now() - timedelta(days=before_days)
|
||||||
|
|
||||||
|
# 删除旧日志
|
||||||
|
deleted_count = await system_log_service.delete_logs_before_date(db, before_date)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message=f"成功清理 {deleted_count} 条日志",
|
||||||
|
data={"deleted_count": deleted_count}
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"清理日志失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"清理日志失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
228
backend/app/api/v1/tasks.py
Normal file
228
backend/app/api/v1/tasks.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"""
|
||||||
|
任务管理API
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.core.deps import get_db, get_current_user, require_admin_or_manager
|
||||||
|
from app.schemas.base import ResponseModel, PaginatedResponse
|
||||||
|
from app.schemas.task import TaskCreate, TaskUpdate, TaskResponse, TaskStatsResponse
|
||||||
|
from app.services.task_service import task_service
|
||||||
|
from app.services.system_log_service import system_log_service
|
||||||
|
from app.schemas.system_log import SystemLogCreate
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/manager/tasks", tags=["Tasks"], redirect_slashes=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ResponseModel[TaskResponse], summary="创建任务")
|
||||||
|
async def create_task(
|
||||||
|
task_in: TaskCreate,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_admin_or_manager)
|
||||||
|
):
|
||||||
|
"""创建新任务"""
|
||||||
|
task = await task_service.create_task(db, task_in, current_user.id)
|
||||||
|
|
||||||
|
# 记录任务创建日志
|
||||||
|
await system_log_service.create_log(
|
||||||
|
db,
|
||||||
|
SystemLogCreate(
|
||||||
|
level="INFO",
|
||||||
|
type="api",
|
||||||
|
message=f"创建任务: {task.title}",
|
||||||
|
user_id=current_user.id,
|
||||||
|
user=current_user.username,
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
path="/api/v1/manager/tasks",
|
||||||
|
method="POST",
|
||||||
|
user_agent=request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建响应
|
||||||
|
courses = [link.course.name for link in task.course_links]
|
||||||
|
return ResponseModel(
|
||||||
|
data=TaskResponse(
|
||||||
|
id=task.id,
|
||||||
|
title=task.title,
|
||||||
|
description=task.description,
|
||||||
|
priority=task.priority.value,
|
||||||
|
status=task.status.value,
|
||||||
|
creator_id=task.creator_id,
|
||||||
|
deadline=task.deadline,
|
||||||
|
requirements=task.requirements,
|
||||||
|
progress=task.progress,
|
||||||
|
created_at=task.created_at,
|
||||||
|
updated_at=task.updated_at,
|
||||||
|
courses=courses,
|
||||||
|
assigned_count=len(task.assignments),
|
||||||
|
completed_count=sum(1 for a in task.assignments if a.status.value == "completed")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ResponseModel[PaginatedResponse[TaskResponse]], summary="获取任务列表")
|
||||||
|
async def get_tasks(
|
||||||
|
status: Optional[str] = Query(None, description="任务状态筛选"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_admin_or_manager)
|
||||||
|
):
|
||||||
|
"""获取任务列表"""
|
||||||
|
tasks, total = await task_service.get_tasks(db, status, page, page_size)
|
||||||
|
|
||||||
|
# 构建响应
|
||||||
|
items = []
|
||||||
|
for task in tasks:
|
||||||
|
# 加载关联数据
|
||||||
|
task_detail = await task_service.get_task_detail(db, task.id)
|
||||||
|
if task_detail:
|
||||||
|
courses = [link.course.name for link in task_detail.course_links]
|
||||||
|
items.append(TaskResponse(
|
||||||
|
id=task.id,
|
||||||
|
title=task.title,
|
||||||
|
description=task.description,
|
||||||
|
priority=task.priority.value,
|
||||||
|
status=task.status.value,
|
||||||
|
creator_id=task.creator_id,
|
||||||
|
deadline=task.deadline,
|
||||||
|
requirements=task.requirements,
|
||||||
|
progress=task.progress,
|
||||||
|
created_at=task.created_at,
|
||||||
|
updated_at=task.updated_at,
|
||||||
|
courses=courses,
|
||||||
|
assigned_count=len(task_detail.assignments),
|
||||||
|
completed_count=sum(1 for a in task_detail.assignments if a.status.value == "completed")
|
||||||
|
))
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
data=PaginatedResponse.create(
|
||||||
|
items=items,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=ResponseModel[TaskStatsResponse], summary="获取任务统计")
|
||||||
|
async def get_task_stats(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_admin_or_manager)
|
||||||
|
):
|
||||||
|
"""获取任务统计数据"""
|
||||||
|
stats = await task_service.get_task_stats(db)
|
||||||
|
return ResponseModel(data=stats)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{task_id}", response_model=ResponseModel[TaskResponse], summary="获取任务详情")
|
||||||
|
async def get_task(
|
||||||
|
task_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_admin_or_manager)
|
||||||
|
):
|
||||||
|
"""获取任务详情"""
|
||||||
|
task = await task_service.get_task_detail(db, task_id)
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
|
||||||
|
courses = [link.course.name for link in task.course_links]
|
||||||
|
return ResponseModel(
|
||||||
|
data=TaskResponse(
|
||||||
|
id=task.id,
|
||||||
|
title=task.title,
|
||||||
|
description=task.description,
|
||||||
|
priority=task.priority.value,
|
||||||
|
status=task.status.value,
|
||||||
|
creator_id=task.creator_id,
|
||||||
|
deadline=task.deadline,
|
||||||
|
requirements=task.requirements,
|
||||||
|
progress=task.progress,
|
||||||
|
created_at=task.created_at,
|
||||||
|
updated_at=task.updated_at,
|
||||||
|
courses=courses,
|
||||||
|
assigned_count=len(task.assignments),
|
||||||
|
completed_count=sum(1 for a in task.assignments if a.status.value == "completed")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{task_id}", response_model=ResponseModel[TaskResponse], summary="更新任务")
|
||||||
|
async def update_task(
|
||||||
|
task_id: int,
|
||||||
|
task_in: TaskUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_admin_or_manager)
|
||||||
|
):
|
||||||
|
"""更新任务"""
|
||||||
|
task = await task_service.update_task(db, task_id, task_in)
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
|
||||||
|
# 自动更新任务进度和状态
|
||||||
|
await task_service.update_task_status(db, task_id)
|
||||||
|
|
||||||
|
# 重新加载详情
|
||||||
|
task_detail = await task_service.get_task_detail(db, task.id)
|
||||||
|
courses = [link.course.name for link in task_detail.course_links] if task_detail else []
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
data=TaskResponse(
|
||||||
|
id=task.id,
|
||||||
|
title=task.title,
|
||||||
|
description=task.description,
|
||||||
|
priority=task.priority.value,
|
||||||
|
status=task.status.value,
|
||||||
|
creator_id=task.creator_id,
|
||||||
|
deadline=task.deadline,
|
||||||
|
requirements=task.requirements,
|
||||||
|
progress=task.progress,
|
||||||
|
created_at=task.created_at,
|
||||||
|
updated_at=task.updated_at,
|
||||||
|
courses=courses,
|
||||||
|
assigned_count=len(task_detail.assignments) if task_detail else 0,
|
||||||
|
completed_count=sum(1 for a in task_detail.assignments if a.status.value == "completed") if task_detail else 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{task_id}", response_model=ResponseModel, summary="删除任务")
|
||||||
|
async def delete_task(
|
||||||
|
task_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_admin_or_manager)
|
||||||
|
):
|
||||||
|
"""删除任务"""
|
||||||
|
# 先获取任务信息用于日志
|
||||||
|
task_detail = await task_service.get_task_detail(db, task_id)
|
||||||
|
task_title = task_detail.title if task_detail else f"ID:{task_id}"
|
||||||
|
|
||||||
|
success = await task_service.delete_task(db, task_id)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
|
||||||
|
# 记录任务删除日志
|
||||||
|
await system_log_service.create_log(
|
||||||
|
db,
|
||||||
|
SystemLogCreate(
|
||||||
|
level="INFO",
|
||||||
|
type="api",
|
||||||
|
message=f"删除任务: {task_title}",
|
||||||
|
user_id=current_user.id,
|
||||||
|
user=current_user.username,
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
path=f"/api/v1/manager/tasks/{task_id}",
|
||||||
|
method="DELETE",
|
||||||
|
user_agent=request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(message="任务已删除")
|
||||||
|
|
||||||
750
backend/app/api/v1/team_dashboard.py
Normal file
750
backend/app/api/v1/team_dashboard.py
Normal file
@@ -0,0 +1,750 @@
|
|||||||
|
"""
|
||||||
|
团队看板 API 路由
|
||||||
|
提供团队概览、学习进度、排行榜、动态等数据
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_current_active_user as get_current_user, get_db
|
||||||
|
from app.core.logger import logger
|
||||||
|
from app.models.course import Course
|
||||||
|
from app.models.exam import Exam
|
||||||
|
from app.models.position import Position
|
||||||
|
from app.models.position_member import PositionMember
|
||||||
|
from app.models.practice import PracticeReport, PracticeSession
|
||||||
|
from app.models.user import Team, User, UserTeam
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/team/dashboard", tags=["team-dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
async def get_accessible_teams(
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession
|
||||||
|
) -> List[int]:
|
||||||
|
"""获取用户可访问的团队ID列表"""
|
||||||
|
if current_user.role in ['admin', 'manager']:
|
||||||
|
# 管理员查看所有团队
|
||||||
|
stmt = select(Team.id).where(Team.is_deleted == False) # noqa: E712
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return [row[0] for row in result.all()]
|
||||||
|
else:
|
||||||
|
# 普通用户只查看自己的团队
|
||||||
|
stmt = select(UserTeam.team_id).where(UserTeam.user_id == current_user.id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return [row[0] for row in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_team_member_ids(
|
||||||
|
team_ids: List[int],
|
||||||
|
db: AsyncSession
|
||||||
|
) -> List[int]:
|
||||||
|
"""获取团队成员ID列表"""
|
||||||
|
if not team_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
stmt = select(UserTeam.user_id).where(
|
||||||
|
UserTeam.team_id.in_(team_ids)
|
||||||
|
).distinct()
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return [row[0] for row in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/overview", response_model=ResponseModel)
|
||||||
|
async def get_team_overview(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取团队概览统计
|
||||||
|
|
||||||
|
返回团队总数、成员数、平均学习进度、平均成绩、课程完成率等
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 获取可访问的团队
|
||||||
|
team_ids = await get_accessible_teams(current_user, db)
|
||||||
|
|
||||||
|
# 获取团队成员ID
|
||||||
|
member_ids = await get_team_member_ids(team_ids, db)
|
||||||
|
|
||||||
|
# 统计团队数
|
||||||
|
team_count = len(team_ids)
|
||||||
|
|
||||||
|
# 统计成员数
|
||||||
|
member_count = len(member_ids)
|
||||||
|
|
||||||
|
# 计算平均考试成绩(使用round1_score)
|
||||||
|
avg_score = 0.0
|
||||||
|
if member_ids:
|
||||||
|
stmt = select(func.avg(Exam.round1_score)).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id.in_(member_ids),
|
||||||
|
Exam.round1_score.isnot(None),
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
avg_score_value = result.scalar()
|
||||||
|
avg_score = float(avg_score_value) if avg_score_value else 0.0
|
||||||
|
|
||||||
|
# 计算平均学习进度(基于考试完成情况)
|
||||||
|
avg_progress = 0.0
|
||||||
|
if member_ids:
|
||||||
|
# 统计每个成员完成的考试数
|
||||||
|
stmt = select(func.count(Exam.id)).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id.in_(member_ids),
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
completed_exams = result.scalar() or 0
|
||||||
|
|
||||||
|
# 假设每个成员应完成10个考试,计算完成率作为进度
|
||||||
|
total_expected = member_count * 10
|
||||||
|
if total_expected > 0:
|
||||||
|
avg_progress = (completed_exams / total_expected) * 100
|
||||||
|
|
||||||
|
# 计算课程完成率
|
||||||
|
course_completion_rate = 0.0
|
||||||
|
if member_ids:
|
||||||
|
# 统计已完成的课程数(有考试记录且成绩>=60)
|
||||||
|
stmt = select(func.count(func.distinct(Exam.course_id))).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id.in_(member_ids),
|
||||||
|
Exam.round1_score >= 60,
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
completed_courses = result.scalar() or 0
|
||||||
|
|
||||||
|
# 统计总课程数
|
||||||
|
stmt = select(func.count(Course.id)).where(
|
||||||
|
and_(
|
||||||
|
Course.is_deleted == False, # noqa: E712
|
||||||
|
Course.status == 'published'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
total_courses = result.scalar() or 0
|
||||||
|
|
||||||
|
if total_courses > 0:
|
||||||
|
course_completion_rate = (completed_courses / total_courses) * 100
|
||||||
|
|
||||||
|
# 趋势数据(暂时返回固定值,后续可实现真实趋势计算)
|
||||||
|
trends = {
|
||||||
|
"member_trend": 0,
|
||||||
|
"progress_trend": 12.3 if avg_progress > 0 else 0,
|
||||||
|
"score_trend": 5.8 if avg_score > 0 else 0,
|
||||||
|
"completion_trend": -3.2 if course_completion_rate > 0 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"team_count": team_count,
|
||||||
|
"member_count": member_count,
|
||||||
|
"avg_progress": round(avg_progress, 1),
|
||||||
|
"avg_score": round(avg_score, 1),
|
||||||
|
"course_completion_rate": round(course_completion_rate, 1),
|
||||||
|
"trends": trends
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseModel(code=200, message="success", data=data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取团队概览失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"获取团队概览失败: {str(e)}", data=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/progress", response_model=ResponseModel)
|
||||||
|
async def get_progress_data(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取学习进度数据
|
||||||
|
|
||||||
|
返回Top 5成员的8周学习进度数据
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 获取可访问的团队
|
||||||
|
team_ids = await get_accessible_teams(current_user, db)
|
||||||
|
member_ids = await get_team_member_ids(team_ids, db)
|
||||||
|
|
||||||
|
if not member_ids:
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data={"members": [], "weeks": [], "data": []}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取Top 5学习时长最高的成员
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
User.id,
|
||||||
|
User.full_name,
|
||||||
|
func.sum(PracticeSession.duration_seconds).label('total_duration')
|
||||||
|
)
|
||||||
|
.join(PracticeSession, PracticeSession.user_id == User.id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
User.id.in_(member_ids),
|
||||||
|
PracticeSession.status == 'completed'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.group_by(User.id, User.full_name)
|
||||||
|
.order_by(func.sum(PracticeSession.duration_seconds).desc())
|
||||||
|
.limit(5)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
top_members = result.all()
|
||||||
|
|
||||||
|
if not top_members:
|
||||||
|
# 如果没有陪练记录,按考试成绩选择Top 5
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
User.id,
|
||||||
|
User.full_name,
|
||||||
|
func.avg(Exam.round1_score).label('avg_score')
|
||||||
|
)
|
||||||
|
.join(Exam, Exam.user_id == User.id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
User.id.in_(member_ids),
|
||||||
|
Exam.round1_score.isnot(None),
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.group_by(User.id, User.full_name)
|
||||||
|
.order_by(func.avg(Exam.round1_score).desc())
|
||||||
|
.limit(5)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
top_members = result.all()
|
||||||
|
|
||||||
|
# 生成周标签
|
||||||
|
weeks = [f"第{i+1}周" for i in range(8)]
|
||||||
|
|
||||||
|
# 为每个成员生成进度数据
|
||||||
|
members = []
|
||||||
|
data = []
|
||||||
|
|
||||||
|
for member in top_members:
|
||||||
|
member_name = member.full_name or f"用户{member.id}"
|
||||||
|
members.append(member_name)
|
||||||
|
|
||||||
|
# 查询该成员8周内的考试完成情况
|
||||||
|
eight_weeks_ago = datetime.now() - timedelta(weeks=8)
|
||||||
|
stmt = select(Exam).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id == member.id,
|
||||||
|
Exam.created_at >= eight_weeks_ago,
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
).order_by(Exam.created_at)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
exams = result.scalars().all()
|
||||||
|
|
||||||
|
# 计算每周的进度(0-100)
|
||||||
|
values = []
|
||||||
|
for week in range(8):
|
||||||
|
week_start = datetime.now() - timedelta(weeks=8-week)
|
||||||
|
week_end = week_start + timedelta(weeks=1)
|
||||||
|
|
||||||
|
# 统计该周完成的考试数
|
||||||
|
week_exams = [
|
||||||
|
e for e in exams
|
||||||
|
if week_start <= e.created_at < week_end
|
||||||
|
]
|
||||||
|
|
||||||
|
# 进度 = 累计完成考试数 * 10(假设每个考试代表10%进度)
|
||||||
|
cumulative_exams = len([e for e in exams if e.created_at < week_end])
|
||||||
|
progress = min(cumulative_exams * 10, 100)
|
||||||
|
values.append(progress)
|
||||||
|
|
||||||
|
data.append({"name": member_name, "values": values})
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data={"members": members, "weeks": weeks, "data": data}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取学习进度数据失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"获取学习进度数据失败: {str(e)}", data=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/course-distribution", response_model=ResponseModel)
|
||||||
|
async def get_course_distribution(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取课程完成分布
|
||||||
|
|
||||||
|
返回已完成、进行中、未开始的课程数量
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 获取可访问的团队
|
||||||
|
team_ids = await get_accessible_teams(current_user, db)
|
||||||
|
member_ids = await get_team_member_ids(team_ids, db)
|
||||||
|
|
||||||
|
# 统计所有已发布的课程
|
||||||
|
stmt = select(func.count(Course.id)).where(
|
||||||
|
and_(
|
||||||
|
Course.is_deleted == False, # noqa: E712
|
||||||
|
Course.status == 'published'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
total_courses = result.scalar() or 0
|
||||||
|
|
||||||
|
if not member_ids or total_courses == 0:
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data={"completed": 0, "in_progress": 0, "not_started": 0}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 统计已完成的课程(有及格成绩)
|
||||||
|
stmt = select(func.count(func.distinct(Exam.course_id))).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id.in_(member_ids),
|
||||||
|
Exam.round1_score >= 60,
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
completed = result.scalar() or 0
|
||||||
|
|
||||||
|
# 统计进行中的课程(有考试记录但未及格)
|
||||||
|
stmt = select(func.count(func.distinct(Exam.course_id))).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id.in_(member_ids),
|
||||||
|
or_(
|
||||||
|
Exam.round1_score < 60,
|
||||||
|
Exam.status == 'started'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
in_progress = result.scalar() or 0
|
||||||
|
|
||||||
|
# 未开始 = 总数 - 已完成 - 进行中
|
||||||
|
not_started = max(0, total_courses - completed - in_progress)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"completed": completed,
|
||||||
|
"in_progress": in_progress,
|
||||||
|
"not_started": not_started
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseModel(code=200, message="success", data=data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取课程分布失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"获取课程分布失败: {str(e)}", data=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ability-analysis", response_model=ResponseModel)
|
||||||
|
async def get_ability_analysis(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取能力分析数据
|
||||||
|
|
||||||
|
返回团队能力雷达图数据和短板列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 获取可访问的团队
|
||||||
|
team_ids = await get_accessible_teams(current_user, db)
|
||||||
|
member_ids = await get_team_member_ids(team_ids, db)
|
||||||
|
|
||||||
|
if not member_ids:
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data={
|
||||||
|
"radar_data": {
|
||||||
|
"dimensions": [],
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"weaknesses": []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 查询所有陪练报告的能力维度数据
|
||||||
|
# 需要通过PracticeSession关联,因为PracticeReport没有user_id
|
||||||
|
stmt = (
|
||||||
|
select(PracticeReport.ability_dimensions)
|
||||||
|
.join(PracticeSession, PracticeSession.session_id == PracticeReport.session_id)
|
||||||
|
.where(PracticeSession.user_id.in_(member_ids))
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
all_dimensions = result.scalars().all()
|
||||||
|
|
||||||
|
if not all_dimensions:
|
||||||
|
# 如果没有陪练报告,返回默认能力维度
|
||||||
|
default_dimensions = ["沟通表达", "倾听理解", "需求挖掘", "异议处理", "成交技巧", "客户维护"]
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data={
|
||||||
|
"radar_data": {
|
||||||
|
"dimensions": default_dimensions,
|
||||||
|
"values": [0] * len(default_dimensions)
|
||||||
|
},
|
||||||
|
"weaknesses": []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 聚合能力数据
|
||||||
|
ability_scores: Dict[str, List[float]] = {}
|
||||||
|
|
||||||
|
# 能力维度名称映射
|
||||||
|
dimension_name_map = {
|
||||||
|
"sales_ability": "销售能力",
|
||||||
|
"service_attitude": "服务态度",
|
||||||
|
"technical_skills": "技术能力",
|
||||||
|
"沟通表达": "沟通表达",
|
||||||
|
"倾听理解": "倾听理解",
|
||||||
|
"需求挖掘": "需求挖掘",
|
||||||
|
"异议处理": "异议处理",
|
||||||
|
"成交技巧": "成交技巧",
|
||||||
|
"客户维护": "客户维护"
|
||||||
|
}
|
||||||
|
|
||||||
|
for dimensions in all_dimensions:
|
||||||
|
if dimensions:
|
||||||
|
# 如果是字符串,进行JSON反序列化
|
||||||
|
if isinstance(dimensions, str):
|
||||||
|
try:
|
||||||
|
dimensions = json.loads(dimensions)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning(f"无法解析能力维度数据: {dimensions}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 处理字典格式:{"sales_ability": 79.0, ...}
|
||||||
|
if isinstance(dimensions, dict):
|
||||||
|
for key, score in dimensions.items():
|
||||||
|
name = dimension_name_map.get(key, key)
|
||||||
|
if name not in ability_scores:
|
||||||
|
ability_scores[name] = []
|
||||||
|
ability_scores[name].append(float(score))
|
||||||
|
|
||||||
|
# 处理列表格式:[{"name": "沟通表达", "score": 85}, ...]
|
||||||
|
elif isinstance(dimensions, list):
|
||||||
|
for dim in dimensions:
|
||||||
|
if not isinstance(dim, dict):
|
||||||
|
logger.warning(f"能力维度项格式错误: {type(dim)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = dim.get('name', '')
|
||||||
|
score = dim.get('score', 0)
|
||||||
|
if name:
|
||||||
|
mapped_name = dimension_name_map.get(name, name)
|
||||||
|
if mapped_name not in ability_scores:
|
||||||
|
ability_scores[mapped_name] = []
|
||||||
|
ability_scores[mapped_name].append(float(score))
|
||||||
|
else:
|
||||||
|
logger.warning(f"能力维度数据格式错误: {type(dimensions)}")
|
||||||
|
|
||||||
|
# 计算平均分
|
||||||
|
avg_scores = {
|
||||||
|
name: sum(scores) / len(scores)
|
||||||
|
for name, scores in ability_scores.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
# 按固定顺序排列维度(支持多种维度组合)
|
||||||
|
# 优先使用六维度,如果没有则使用三维度
|
||||||
|
standard_dimensions_six = ["沟通表达", "倾听理解", "需求挖掘", "异议处理", "成交技巧", "客户维护"]
|
||||||
|
standard_dimensions_three = ["销售能力", "服务态度", "技术能力"]
|
||||||
|
|
||||||
|
# 判断使用哪种维度标准
|
||||||
|
has_six_dimensions = any(dim in avg_scores for dim in standard_dimensions_six)
|
||||||
|
has_three_dimensions = any(dim in avg_scores for dim in standard_dimensions_three)
|
||||||
|
|
||||||
|
if has_six_dimensions:
|
||||||
|
standard_dimensions = standard_dimensions_six
|
||||||
|
elif has_three_dimensions:
|
||||||
|
standard_dimensions = standard_dimensions_three
|
||||||
|
else:
|
||||||
|
# 如果都没有,使用实际数据的维度
|
||||||
|
standard_dimensions = list(avg_scores.keys())
|
||||||
|
|
||||||
|
dimensions = []
|
||||||
|
values = []
|
||||||
|
|
||||||
|
for dim in standard_dimensions:
|
||||||
|
if dim in avg_scores:
|
||||||
|
dimensions.append(dim)
|
||||||
|
values.append(round(avg_scores[dim], 1))
|
||||||
|
|
||||||
|
# 找出短板(平均分<80)
|
||||||
|
weaknesses = []
|
||||||
|
weakness_suggestions = {
|
||||||
|
# 六维度建议
|
||||||
|
"异议处理": "建议加强异议处理专项训练,增加实战演练",
|
||||||
|
"成交技巧": "需要系统学习成交话术和时机把握",
|
||||||
|
"需求挖掘": "提升提问技巧,深入了解客户需求",
|
||||||
|
"沟通表达": "加强沟通技巧训练,提升表达能力",
|
||||||
|
"倾听理解": "培养同理心,提高倾听和理解能力",
|
||||||
|
"客户维护": "学习客户关系管理,提升服务质量",
|
||||||
|
# 三维度建议
|
||||||
|
"销售能力": "建议加强销售技巧训练,提升成交率",
|
||||||
|
"服务态度": "需要改善服务态度,提高客户满意度",
|
||||||
|
"技术能力": "建议学习产品知识,提升专业能力"
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, score in avg_scores.items():
|
||||||
|
if score < 80:
|
||||||
|
weaknesses.append({
|
||||||
|
"name": name,
|
||||||
|
"avg_score": int(score),
|
||||||
|
"suggestion": weakness_suggestions.get(name, f"建议加强{name}专项训练")
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按分数升序排列
|
||||||
|
weaknesses.sort(key=lambda x: x['avg_score'])
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"radar_data": {
|
||||||
|
"dimensions": dimensions,
|
||||||
|
"values": values
|
||||||
|
},
|
||||||
|
"weaknesses": weaknesses
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseModel(code=200, message="success", data=data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取能力分析失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"获取能力分析失败: {str(e)}", data=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rankings", response_model=ResponseModel)
|
||||||
|
async def get_rankings(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取排行榜数据
|
||||||
|
|
||||||
|
返回学习时长排行和成绩排行Top 5
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 获取可访问的团队
|
||||||
|
team_ids = await get_accessible_teams(current_user, db)
|
||||||
|
member_ids = await get_team_member_ids(team_ids, db)
|
||||||
|
|
||||||
|
if not member_ids:
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data={
|
||||||
|
"study_time_ranking": [],
|
||||||
|
"score_ranking": []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 学习时长排行(基于陪练会话)
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
User.id,
|
||||||
|
User.full_name,
|
||||||
|
User.avatar_url,
|
||||||
|
Position.name.label('position_name'),
|
||||||
|
func.sum(PracticeSession.duration_seconds).label('total_duration')
|
||||||
|
)
|
||||||
|
.join(PracticeSession, PracticeSession.user_id == User.id)
|
||||||
|
.outerjoin(PositionMember, and_(
|
||||||
|
PositionMember.user_id == User.id,
|
||||||
|
PositionMember.is_deleted == False # noqa: E712
|
||||||
|
))
|
||||||
|
.outerjoin(Position, Position.id == PositionMember.position_id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
User.id.in_(member_ids),
|
||||||
|
PracticeSession.status == 'completed'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.group_by(User.id, User.full_name, User.avatar_url, Position.name)
|
||||||
|
.order_by(func.sum(PracticeSession.duration_seconds).desc())
|
||||||
|
.limit(5)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
study_time_data = result.all()
|
||||||
|
|
||||||
|
study_time_ranking = []
|
||||||
|
for row in study_time_data:
|
||||||
|
study_time_ranking.append({
|
||||||
|
"id": row.id,
|
||||||
|
"name": row.full_name or f"用户{row.id}",
|
||||||
|
"position": row.position_name or "未分配岗位",
|
||||||
|
"avatar": row.avatar_url or "",
|
||||||
|
"study_time": round(row.total_duration / 3600, 1) # 转换为小时
|
||||||
|
})
|
||||||
|
|
||||||
|
# 成绩排行(基于考试round1_score)
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
User.id,
|
||||||
|
User.full_name,
|
||||||
|
User.avatar_url,
|
||||||
|
Position.name.label('position_name'),
|
||||||
|
func.avg(Exam.round1_score).label('avg_score')
|
||||||
|
)
|
||||||
|
.join(Exam, Exam.user_id == User.id)
|
||||||
|
.outerjoin(PositionMember, and_(
|
||||||
|
PositionMember.user_id == User.id,
|
||||||
|
PositionMember.is_deleted == False # noqa: E712
|
||||||
|
))
|
||||||
|
.outerjoin(Position, Position.id == PositionMember.position_id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
User.id.in_(member_ids),
|
||||||
|
Exam.round1_score.isnot(None),
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.group_by(User.id, User.full_name, User.avatar_url, Position.name)
|
||||||
|
.order_by(func.avg(Exam.round1_score).desc())
|
||||||
|
.limit(5)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
score_data = result.all()
|
||||||
|
|
||||||
|
score_ranking = []
|
||||||
|
for row in score_data:
|
||||||
|
score_ranking.append({
|
||||||
|
"id": row.id,
|
||||||
|
"name": row.full_name or f"用户{row.id}",
|
||||||
|
"position": row.position_name or "未分配岗位",
|
||||||
|
"avatar": row.avatar_url or "",
|
||||||
|
"avg_score": round(row.avg_score, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"study_time_ranking": study_time_ranking,
|
||||||
|
"score_ranking": score_ranking
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseModel(code=200, message="success", data=data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取排行榜失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"获取排行榜失败: {str(e)}", data=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/activities", response_model=ResponseModel)
|
||||||
|
async def get_activities(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取团队学习动态
|
||||||
|
|
||||||
|
返回最近20条活动记录(考试、陪练等)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 获取可访问的团队
|
||||||
|
team_ids = await get_accessible_teams(current_user, db)
|
||||||
|
member_ids = await get_team_member_ids(team_ids, db)
|
||||||
|
|
||||||
|
if not member_ids:
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data={"activities": []}
|
||||||
|
)
|
||||||
|
|
||||||
|
activities = []
|
||||||
|
|
||||||
|
# 获取最近的考试记录
|
||||||
|
stmt = (
|
||||||
|
select(Exam, User.full_name, Course.name.label('course_name'))
|
||||||
|
.join(User, User.id == Exam.user_id)
|
||||||
|
.join(Course, Course.id == Exam.course_id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id.in_(member_ids),
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Exam.updated_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
exam_records = result.all()
|
||||||
|
|
||||||
|
for exam, user_name, course_name in exam_records:
|
||||||
|
score = exam.round1_score or 0
|
||||||
|
activity_type = "success" if score >= 60 else "danger"
|
||||||
|
result_type = "success" if score >= 60 else "danger"
|
||||||
|
result_text = f"成绩:{int(score)}分" if score >= 60 else "未通过"
|
||||||
|
|
||||||
|
activities.append({
|
||||||
|
"id": f"exam_{exam.id}",
|
||||||
|
"user_name": user_name or f"用户{exam.user_id}",
|
||||||
|
"action": "完成了" if score >= 60 else "参加了",
|
||||||
|
"target": f"《{course_name}》课程考试",
|
||||||
|
"time": exam.updated_at.strftime("%Y-%m-%d %H:%M"),
|
||||||
|
"type": activity_type,
|
||||||
|
"result": {"type": result_type, "text": result_text}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 获取最近的陪练记录
|
||||||
|
stmt = (
|
||||||
|
select(PracticeSession, User.full_name, PracticeReport.total_score)
|
||||||
|
.join(User, User.id == PracticeSession.user_id)
|
||||||
|
.outerjoin(PracticeReport, PracticeReport.session_id == PracticeSession.session_id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
PracticeSession.user_id.in_(member_ids),
|
||||||
|
PracticeSession.status == 'completed'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(PracticeSession.end_time.desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
practice_records = result.all()
|
||||||
|
|
||||||
|
for session, user_name, total_score in practice_records:
|
||||||
|
activity_type = "primary"
|
||||||
|
result_data = None
|
||||||
|
if total_score:
|
||||||
|
result_data = {"type": "", "text": f"评分:{int(total_score)}分"}
|
||||||
|
|
||||||
|
activities.append({
|
||||||
|
"id": f"practice_{session.id}",
|
||||||
|
"user_name": user_name or f"用户{session.user_id}",
|
||||||
|
"action": "参加了",
|
||||||
|
"target": "AI陪练训练",
|
||||||
|
"time": session.end_time.strftime("%Y-%m-%d %H:%M") if session.end_time else "",
|
||||||
|
"type": activity_type,
|
||||||
|
"result": result_data
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按时间倒序排列,取前20条
|
||||||
|
activities.sort(key=lambda x: x['time'], reverse=True)
|
||||||
|
activities = activities[:20]
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data={"activities": activities}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取团队动态失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"获取团队动态失败: {str(e)}", data=None)
|
||||||
|
|
||||||
896
backend/app/api/v1/team_management.py
Normal file
896
backend/app/api/v1/team_management.py
Normal file
@@ -0,0 +1,896 @@
|
|||||||
|
"""
|
||||||
|
团队成员管理 API 路由
|
||||||
|
提供团队统计、成员列表、成员详情、学习报告等功能
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_current_active_user as get_current_user, get_db
|
||||||
|
from app.core.logger import logger
|
||||||
|
from app.models.course import Course
|
||||||
|
from app.models.exam import Exam
|
||||||
|
from app.models.position import Position
|
||||||
|
from app.models.position_course import PositionCourse
|
||||||
|
from app.models.position_member import PositionMember
|
||||||
|
from app.models.practice import PracticeReport, PracticeSession
|
||||||
|
from app.models.user import User, UserTeam
|
||||||
|
from app.schemas.base import PaginatedResponse, ResponseModel
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/team/management", tags=["team-management"])
|
||||||
|
|
||||||
|
|
||||||
|
async def get_accessible_team_member_ids(
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession
|
||||||
|
) -> List[int]:
|
||||||
|
"""获取用户可访问的团队成员ID列表"""
|
||||||
|
if current_user.role in ['admin', 'manager']:
|
||||||
|
# 管理员查看所有团队成员
|
||||||
|
stmt = select(UserTeam.user_id).distinct()
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return [row[0] for row in result.all()]
|
||||||
|
else:
|
||||||
|
# 普通用户只查看自己团队的成员
|
||||||
|
# 1. 先查询用户所在的团队
|
||||||
|
stmt = select(UserTeam.team_id).where(UserTeam.user_id == current_user.id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
team_ids = [row[0] for row in result.all()]
|
||||||
|
|
||||||
|
if not team_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 2. 查询这些团队的所有成员
|
||||||
|
stmt = select(UserTeam.user_id).where(
|
||||||
|
UserTeam.team_id.in_(team_ids)
|
||||||
|
).distinct()
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return [row[0] for row in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_member_status(
|
||||||
|
last_login: Optional[datetime],
|
||||||
|
last_exam: Optional[datetime],
|
||||||
|
last_practice: Optional[datetime],
|
||||||
|
has_ongoing: bool
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
计算成员活跃状态
|
||||||
|
|
||||||
|
Args:
|
||||||
|
last_login: 最后登录时间
|
||||||
|
last_exam: 最后考试时间
|
||||||
|
last_practice: 最后陪练时间
|
||||||
|
has_ongoing: 是否有进行中的活动
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
状态: active(活跃), learning(学习中), rest(休息)
|
||||||
|
"""
|
||||||
|
# 获取最近活跃时间
|
||||||
|
times = [t for t in [last_login, last_exam, last_practice] if t is not None]
|
||||||
|
if not times:
|
||||||
|
return 'rest'
|
||||||
|
|
||||||
|
last_active = max(times)
|
||||||
|
thirty_days_ago = datetime.now() - timedelta(days=30)
|
||||||
|
|
||||||
|
# 判断状态
|
||||||
|
if last_active >= thirty_days_ago:
|
||||||
|
if has_ongoing:
|
||||||
|
return 'learning'
|
||||||
|
else:
|
||||||
|
return 'active'
|
||||||
|
else:
|
||||||
|
return 'rest'
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/statistics", response_model=ResponseModel)
|
||||||
|
async def get_team_statistics(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取团队统计数据
|
||||||
|
|
||||||
|
返回:团队总人数、活跃成员数、平均学习进度、团队平均分
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 获取可访问的团队成员ID
|
||||||
|
member_ids = await get_accessible_team_member_ids(current_user, db)
|
||||||
|
|
||||||
|
# 团队总人数
|
||||||
|
team_count = len(member_ids)
|
||||||
|
|
||||||
|
if team_count == 0:
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data={
|
||||||
|
"teamCount": 0,
|
||||||
|
"activeMembers": 0,
|
||||||
|
"avgProgress": 0,
|
||||||
|
"avgScore": 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 统计活跃成员数(最近30天有活动)
|
||||||
|
thirty_days_ago = datetime.now() - timedelta(days=30)
|
||||||
|
|
||||||
|
# 统计最近30天有登录或有考试或有陪练的用户
|
||||||
|
active_users_stmt = select(func.count(func.distinct(User.id))).where(
|
||||||
|
and_(
|
||||||
|
User.id.in_(member_ids),
|
||||||
|
or_(
|
||||||
|
User.last_login_at >= thirty_days_ago,
|
||||||
|
User.id.in_(
|
||||||
|
select(Exam.user_id).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id.in_(member_ids),
|
||||||
|
Exam.created_at >= thirty_days_ago
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
User.id.in_(
|
||||||
|
select(PracticeSession.user_id).where(
|
||||||
|
and_(
|
||||||
|
PracticeSession.user_id.in_(member_ids),
|
||||||
|
PracticeSession.start_time >= thirty_days_ago
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(active_users_stmt)
|
||||||
|
active_members = result.scalar() or 0
|
||||||
|
|
||||||
|
# 计算平均学习进度(每个成员的完成课程/应完成课程的平均值)
|
||||||
|
# 统计每个成员的进度,然后计算平均值
|
||||||
|
total_progress = 0.0
|
||||||
|
members_with_courses = 0
|
||||||
|
|
||||||
|
for member_id in member_ids:
|
||||||
|
# 获取该成员岗位分配的课程数
|
||||||
|
member_courses_stmt = select(
|
||||||
|
func.count(func.distinct(PositionCourse.course_id))
|
||||||
|
).select_from(PositionMember).join(
|
||||||
|
PositionCourse,
|
||||||
|
PositionCourse.position_id == PositionMember.position_id
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
PositionMember.user_id == member_id,
|
||||||
|
PositionMember.is_deleted == False # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(member_courses_stmt)
|
||||||
|
member_total_courses = result.scalar() or 0
|
||||||
|
|
||||||
|
if member_total_courses > 0:
|
||||||
|
# 获取该成员已完成(及格)的课程数
|
||||||
|
member_completed_stmt = select(
|
||||||
|
func.count(func.distinct(Exam.course_id))
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id == member_id,
|
||||||
|
Exam.round1_score >= 60,
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(member_completed_stmt)
|
||||||
|
member_completed = result.scalar() or 0
|
||||||
|
|
||||||
|
# 计算该成员的进度(最大100%)
|
||||||
|
member_progress = min((member_completed / member_total_courses) * 100, 100)
|
||||||
|
total_progress += member_progress
|
||||||
|
members_with_courses += 1
|
||||||
|
|
||||||
|
avg_progress = round(total_progress / members_with_courses, 1) if members_with_courses > 0 else 0.0
|
||||||
|
|
||||||
|
# 计算团队平均分(使用round1_score)
|
||||||
|
avg_score_stmt = select(func.avg(Exam.round1_score)).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id.in_(member_ids),
|
||||||
|
Exam.round1_score.isnot(None),
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(avg_score_stmt)
|
||||||
|
avg_score_value = result.scalar()
|
||||||
|
avg_score = round(float(avg_score_value), 1) if avg_score_value else 0.0
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"teamCount": team_count,
|
||||||
|
"activeMembers": active_members,
|
||||||
|
"avgProgress": avg_progress,
|
||||||
|
"avgScore": avg_score
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseModel(code=200, message="success", data=data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取团队统计失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"获取团队统计失败: {str(e)}", data=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/members", response_model=ResponseModel[PaginatedResponse])
|
||||||
|
async def get_team_members(
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
search_text: Optional[str] = Query(None, description="搜索姓名、岗位"),
|
||||||
|
status: Optional[str] = Query(None, description="筛选状态: active/learning/rest"),
|
||||||
|
position: Optional[str] = Query(None, description="筛选岗位"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取团队成员列表(带筛选、搜索、分页)
|
||||||
|
|
||||||
|
返回成员基本信息、学习进度、成绩、学习时长等
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 获取可访问的团队成员ID
|
||||||
|
member_ids = await get_accessible_team_member_ids(current_user, db)
|
||||||
|
|
||||||
|
if not member_ids:
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data=PaginatedResponse(
|
||||||
|
items=[],
|
||||||
|
total=0,
|
||||||
|
page=page,
|
||||||
|
page_size=size,
|
||||||
|
pages=0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建基础查询
|
||||||
|
stmt = select(User).where(
|
||||||
|
and_(
|
||||||
|
User.id.in_(member_ids),
|
||||||
|
User.is_deleted == False # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 搜索条件(姓名)
|
||||||
|
if search_text:
|
||||||
|
like_pattern = f"%{search_text}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
User.full_name.ilike(like_pattern),
|
||||||
|
User.username.ilike(like_pattern)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 先获取所有符合条件的用户,然后在Python中过滤状态和岗位
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
all_users = result.scalars().all()
|
||||||
|
|
||||||
|
# 为每个用户计算详细信息
|
||||||
|
member_list = []
|
||||||
|
thirty_days_ago = datetime.now() - timedelta(days=30)
|
||||||
|
|
||||||
|
for user in all_users:
|
||||||
|
# 获取用户岗位
|
||||||
|
position_stmt = select(Position.name).select_from(PositionMember).join(
|
||||||
|
Position,
|
||||||
|
Position.id == PositionMember.position_id
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
PositionMember.user_id == user.id,
|
||||||
|
PositionMember.is_deleted == False # noqa: E712
|
||||||
|
)
|
||||||
|
).limit(1)
|
||||||
|
result = await db.execute(position_stmt)
|
||||||
|
position_name = result.scalar()
|
||||||
|
|
||||||
|
# 如果有岗位筛选且不匹配,跳过
|
||||||
|
if position and position_name != position:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 获取最近考试时间
|
||||||
|
last_exam_stmt = select(func.max(Exam.created_at)).where(
|
||||||
|
Exam.user_id == user.id
|
||||||
|
)
|
||||||
|
result = await db.execute(last_exam_stmt)
|
||||||
|
last_exam = result.scalar()
|
||||||
|
|
||||||
|
# 获取最近陪练时间
|
||||||
|
last_practice_stmt = select(func.max(PracticeSession.start_time)).where(
|
||||||
|
PracticeSession.user_id == user.id
|
||||||
|
)
|
||||||
|
result = await db.execute(last_practice_stmt)
|
||||||
|
last_practice = result.scalar()
|
||||||
|
|
||||||
|
# 检查是否有进行中的活动
|
||||||
|
has_ongoing_stmt = select(func.count(Exam.id)).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id == user.id,
|
||||||
|
Exam.status == 'started'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(has_ongoing_stmt)
|
||||||
|
has_ongoing = (result.scalar() or 0) > 0
|
||||||
|
|
||||||
|
# 计算状态
|
||||||
|
member_status = calculate_member_status(
|
||||||
|
user.last_login_at,
|
||||||
|
last_exam,
|
||||||
|
last_practice,
|
||||||
|
has_ongoing
|
||||||
|
)
|
||||||
|
|
||||||
|
# 如果有状态筛选且不匹配,跳过
|
||||||
|
if status and member_status != status:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 统计学习进度
|
||||||
|
# 1. 获取岗位分配的课程总数
|
||||||
|
total_courses_stmt = select(
|
||||||
|
func.count(func.distinct(PositionCourse.course_id))
|
||||||
|
).select_from(PositionMember).join(
|
||||||
|
PositionCourse,
|
||||||
|
PositionCourse.position_id == PositionMember.position_id
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
PositionMember.user_id == user.id,
|
||||||
|
PositionMember.is_deleted == False # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(total_courses_stmt)
|
||||||
|
total_courses = result.scalar() or 0
|
||||||
|
|
||||||
|
# 2. 统计已完成的考试(及格)
|
||||||
|
completed_courses_stmt = select(
|
||||||
|
func.count(func.distinct(Exam.course_id))
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id == user.id,
|
||||||
|
Exam.round1_score >= 60,
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(completed_courses_stmt)
|
||||||
|
completed_courses = result.scalar() or 0
|
||||||
|
|
||||||
|
# 3. 计算进度
|
||||||
|
progress = 0
|
||||||
|
if total_courses > 0:
|
||||||
|
progress = int((completed_courses / total_courses) * 100)
|
||||||
|
|
||||||
|
# 统计平均成绩
|
||||||
|
avg_score_stmt = select(func.avg(Exam.round1_score)).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id == user.id,
|
||||||
|
Exam.round1_score.isnot(None),
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(avg_score_stmt)
|
||||||
|
avg_score_value = result.scalar()
|
||||||
|
avg_score = round(float(avg_score_value), 1) if avg_score_value else 0.0
|
||||||
|
|
||||||
|
# 统计学习时长(考试时长+陪练时长)
|
||||||
|
exam_time_stmt = select(
|
||||||
|
func.coalesce(func.sum(Exam.duration_minutes), 0)
|
||||||
|
).where(Exam.user_id == user.id)
|
||||||
|
result = await db.execute(exam_time_stmt)
|
||||||
|
exam_minutes = float(result.scalar() or 0)
|
||||||
|
|
||||||
|
practice_time_stmt = select(
|
||||||
|
func.coalesce(func.sum(PracticeSession.duration_seconds), 0)
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
PracticeSession.user_id == user.id,
|
||||||
|
PracticeSession.status == 'completed'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(practice_time_stmt)
|
||||||
|
practice_seconds = float(result.scalar() or 0)
|
||||||
|
|
||||||
|
total_hours = round(exam_minutes / 60 + practice_seconds / 3600, 1)
|
||||||
|
|
||||||
|
# 获取最近活跃时间
|
||||||
|
active_times = [t for t in [user.last_login_at, last_exam, last_practice] if t is not None]
|
||||||
|
last_active = max(active_times).strftime("%Y-%m-%d %H:%M") if active_times else "-"
|
||||||
|
|
||||||
|
member_list.append({
|
||||||
|
"id": user.id,
|
||||||
|
"name": user.full_name or user.username,
|
||||||
|
"avatar": user.avatar_url or "",
|
||||||
|
"position": position_name or "未分配岗位",
|
||||||
|
"status": member_status,
|
||||||
|
"progress": progress,
|
||||||
|
"completedCourses": completed_courses,
|
||||||
|
"totalCourses": total_courses,
|
||||||
|
"avgScore": avg_score,
|
||||||
|
"studyTime": total_hours,
|
||||||
|
"lastActive": last_active,
|
||||||
|
"joinTime": user.created_at.strftime("%Y-%m-%d") if user.created_at else "-",
|
||||||
|
"email": user.email or "",
|
||||||
|
"phone": user.phone or "",
|
||||||
|
"passRate": 100 if completed_courses > 0 else 0 # 简化计算
|
||||||
|
})
|
||||||
|
|
||||||
|
# 分页
|
||||||
|
total = len(member_list)
|
||||||
|
pages = (total + size - 1) // size if size > 0 else 0
|
||||||
|
start = (page - 1) * size
|
||||||
|
end = start + size
|
||||||
|
items = member_list[start:end]
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="success",
|
||||||
|
data=PaginatedResponse(
|
||||||
|
items=items,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=size,
|
||||||
|
pages=pages
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取团队成员列表失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(
|
||||||
|
code=500,
|
||||||
|
message=f"获取团队成员列表失败: {str(e)}",
|
||||||
|
data=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/members/{member_id}/detail", response_model=ResponseModel)
|
||||||
|
async def get_member_detail(
|
||||||
|
member_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取成员详情
|
||||||
|
|
||||||
|
返回完整的成员信息和最近学习记录
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 权限检查:确保member_id在可访问范围内
|
||||||
|
accessible_ids = await get_accessible_team_member_ids(current_user, db)
|
||||||
|
if member_id not in accessible_ids:
|
||||||
|
return ResponseModel(
|
||||||
|
code=403,
|
||||||
|
message="无权访问该成员信息",
|
||||||
|
data=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取用户基本信息
|
||||||
|
stmt = select(User).where(
|
||||||
|
and_(
|
||||||
|
User.id == member_id,
|
||||||
|
User.is_deleted == False # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return ResponseModel(code=404, message="成员不存在", data=None)
|
||||||
|
|
||||||
|
# 获取岗位
|
||||||
|
position_stmt = select(Position.name).select_from(PositionMember).join(
|
||||||
|
Position,
|
||||||
|
Position.id == PositionMember.position_id
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
PositionMember.user_id == user.id,
|
||||||
|
PositionMember.is_deleted == False # noqa: E712
|
||||||
|
)
|
||||||
|
).limit(1)
|
||||||
|
result = await db.execute(position_stmt)
|
||||||
|
position_name = result.scalar() or "未分配岗位"
|
||||||
|
|
||||||
|
# 计算状态
|
||||||
|
last_exam_stmt = select(func.max(Exam.created_at)).where(Exam.user_id == user.id)
|
||||||
|
result = await db.execute(last_exam_stmt)
|
||||||
|
last_exam = result.scalar()
|
||||||
|
|
||||||
|
last_practice_stmt = select(func.max(PracticeSession.start_time)).where(
|
||||||
|
PracticeSession.user_id == user.id
|
||||||
|
)
|
||||||
|
result = await db.execute(last_practice_stmt)
|
||||||
|
last_practice = result.scalar()
|
||||||
|
|
||||||
|
has_ongoing_stmt = select(func.count(Exam.id)).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id == user.id,
|
||||||
|
Exam.status == 'started'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(has_ongoing_stmt)
|
||||||
|
has_ongoing = (result.scalar() or 0) > 0
|
||||||
|
|
||||||
|
member_status = calculate_member_status(
|
||||||
|
user.last_login_at,
|
||||||
|
last_exam,
|
||||||
|
last_practice,
|
||||||
|
has_ongoing
|
||||||
|
)
|
||||||
|
|
||||||
|
# 统计学习数据
|
||||||
|
# 学习时长
|
||||||
|
exam_time_stmt = select(func.coalesce(func.sum(Exam.duration_minutes), 0)).where(
|
||||||
|
Exam.user_id == user.id
|
||||||
|
)
|
||||||
|
result = await db.execute(exam_time_stmt)
|
||||||
|
exam_minutes = result.scalar() or 0
|
||||||
|
|
||||||
|
practice_time_stmt = select(
|
||||||
|
func.coalesce(func.sum(PracticeSession.duration_seconds), 0)
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
PracticeSession.user_id == user.id,
|
||||||
|
PracticeSession.status == 'completed'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(practice_time_stmt)
|
||||||
|
practice_seconds = result.scalar() or 0
|
||||||
|
|
||||||
|
study_time = round(exam_minutes / 60 + practice_seconds / 3600, 1)
|
||||||
|
|
||||||
|
# 完成课程数
|
||||||
|
completed_courses_stmt = select(
|
||||||
|
func.count(func.distinct(Exam.course_id))
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id == user.id,
|
||||||
|
Exam.round1_score >= 60,
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(completed_courses_stmt)
|
||||||
|
completed_courses = result.scalar() or 0
|
||||||
|
|
||||||
|
# 平均成绩
|
||||||
|
avg_score_stmt = select(func.avg(Exam.round1_score)).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id == user.id,
|
||||||
|
Exam.round1_score.isnot(None),
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(avg_score_stmt)
|
||||||
|
avg_score_value = result.scalar()
|
||||||
|
avg_score = round(float(avg_score_value), 1) if avg_score_value else 0.0
|
||||||
|
|
||||||
|
# 通过率
|
||||||
|
total_exams_stmt = select(func.count(Exam.id)).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id == user.id,
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(total_exams_stmt)
|
||||||
|
total_exams = result.scalar() or 0
|
||||||
|
|
||||||
|
passed_exams_stmt = select(func.count(Exam.id)).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id == user.id,
|
||||||
|
Exam.round1_score >= 60,
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(passed_exams_stmt)
|
||||||
|
passed_exams = result.scalar() or 0
|
||||||
|
|
||||||
|
pass_rate = round((passed_exams / total_exams) * 100) if total_exams > 0 else 0
|
||||||
|
|
||||||
|
# 获取最近学习记录(最近10条考试和陪练)
|
||||||
|
recent_records = []
|
||||||
|
|
||||||
|
# 考试记录
|
||||||
|
exam_records_stmt = (
|
||||||
|
select(Exam, Course.name.label('course_name'))
|
||||||
|
.join(Course, Course.id == Exam.course_id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id == user.id,
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Exam.updated_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
result = await db.execute(exam_records_stmt)
|
||||||
|
exam_records = result.all()
|
||||||
|
|
||||||
|
for exam, course_name in exam_records:
|
||||||
|
score = exam.round1_score or 0
|
||||||
|
record_type = "success" if score >= 60 else "danger"
|
||||||
|
recent_records.append({
|
||||||
|
"id": f"exam_{exam.id}",
|
||||||
|
"time": exam.updated_at.strftime("%Y-%m-%d %H:%M"),
|
||||||
|
"content": f"完成《{course_name}》课程考试,成绩:{int(score)}分",
|
||||||
|
"type": record_type
|
||||||
|
})
|
||||||
|
|
||||||
|
# 陪练记录
|
||||||
|
practice_records_stmt = (
|
||||||
|
select(PracticeSession)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
PracticeSession.user_id == user.id,
|
||||||
|
PracticeSession.status == 'completed'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(PracticeSession.end_time.desc())
|
||||||
|
.limit(5)
|
||||||
|
)
|
||||||
|
result = await db.execute(practice_records_stmt)
|
||||||
|
practice_records = result.scalars().all()
|
||||||
|
|
||||||
|
for session in practice_records:
|
||||||
|
recent_records.append({
|
||||||
|
"id": f"practice_{session.id}",
|
||||||
|
"time": session.end_time.strftime("%Y-%m-%d %H:%M") if session.end_time else "",
|
||||||
|
"content": "参加AI陪练训练",
|
||||||
|
"type": "primary"
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按时间排序
|
||||||
|
recent_records.sort(key=lambda x: x['time'], reverse=True)
|
||||||
|
recent_records = recent_records[:10]
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"id": user.id,
|
||||||
|
"name": user.full_name or user.username,
|
||||||
|
"avatar": user.avatar_url or "",
|
||||||
|
"position": position_name,
|
||||||
|
"status": member_status,
|
||||||
|
"joinTime": user.created_at.strftime("%Y-%m-%d") if user.created_at else "-",
|
||||||
|
"email": user.email or "",
|
||||||
|
"phone": user.phone or "",
|
||||||
|
"studyTime": study_time,
|
||||||
|
"completedCourses": completed_courses,
|
||||||
|
"avgScore": avg_score,
|
||||||
|
"passRate": pass_rate,
|
||||||
|
"recentRecords": recent_records
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseModel(code=200, message="success", data=data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取成员详情失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(
|
||||||
|
code=500,
|
||||||
|
message=f"获取成员详情失败: {str(e)}",
|
||||||
|
data=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/members/{member_id}/report", response_model=ResponseModel)
|
||||||
|
async def get_member_report(
|
||||||
|
member_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取成员学习报告
|
||||||
|
|
||||||
|
返回学习概览、30天进度趋势、能力评估、详细学习记录
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 权限检查
|
||||||
|
accessible_ids = await get_accessible_team_member_ids(current_user, db)
|
||||||
|
if member_id not in accessible_ids:
|
||||||
|
return ResponseModel(code=403, message="无权访问该成员信息", data=None)
|
||||||
|
|
||||||
|
# 获取用户信息
|
||||||
|
stmt = select(User).where(
|
||||||
|
and_(
|
||||||
|
User.id == member_id,
|
||||||
|
User.is_deleted == False # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return ResponseModel(code=404, message="成员不存在", data=None)
|
||||||
|
|
||||||
|
# 1. 报告概览
|
||||||
|
# 学习总时长
|
||||||
|
exam_time_stmt = select(func.coalesce(func.sum(Exam.duration_minutes), 0)).where(
|
||||||
|
Exam.user_id == user.id
|
||||||
|
)
|
||||||
|
result = await db.execute(exam_time_stmt)
|
||||||
|
exam_minutes = result.scalar() or 0
|
||||||
|
|
||||||
|
practice_time_stmt = select(
|
||||||
|
func.coalesce(func.sum(PracticeSession.duration_seconds), 0)
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
PracticeSession.user_id == user.id,
|
||||||
|
PracticeSession.status == 'completed'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(practice_time_stmt)
|
||||||
|
practice_seconds = result.scalar() or 0
|
||||||
|
|
||||||
|
total_hours = round(exam_minutes / 60 + practice_seconds / 3600, 1)
|
||||||
|
|
||||||
|
# 完成课程数
|
||||||
|
completed_courses_stmt = select(
|
||||||
|
func.count(func.distinct(Exam.course_id))
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id == user.id,
|
||||||
|
Exam.round1_score >= 60,
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(completed_courses_stmt)
|
||||||
|
completed_courses = result.scalar() or 0
|
||||||
|
|
||||||
|
# 平均成绩
|
||||||
|
avg_score_stmt = select(func.avg(Exam.round1_score)).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id == user.id,
|
||||||
|
Exam.round1_score.isnot(None),
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(avg_score_stmt)
|
||||||
|
avg_score_value = result.scalar()
|
||||||
|
avg_score = round(float(avg_score_value), 1) if avg_score_value else 0.0
|
||||||
|
|
||||||
|
# 学习排名(简化:在团队中的排名)
|
||||||
|
# TODO: 实现真实排名计算
|
||||||
|
ranking = "第5名"
|
||||||
|
|
||||||
|
overview = [
|
||||||
|
{
|
||||||
|
"label": "学习总时长",
|
||||||
|
"value": f"{total_hours}小时",
|
||||||
|
"icon": "Clock",
|
||||||
|
"color": "#667eea",
|
||||||
|
"bgColor": "rgba(102, 126, 234, 0.1)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "完成课程",
|
||||||
|
"value": f"{completed_courses}门",
|
||||||
|
"icon": "CircleCheck",
|
||||||
|
"color": "#67c23a",
|
||||||
|
"bgColor": "rgba(103, 194, 58, 0.1)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "平均成绩",
|
||||||
|
"value": f"{avg_score}分",
|
||||||
|
"icon": "Trophy",
|
||||||
|
"color": "#e6a23c",
|
||||||
|
"bgColor": "rgba(230, 162, 60, 0.1)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "学习排名",
|
||||||
|
"value": ranking,
|
||||||
|
"icon": "Medal",
|
||||||
|
"color": "#f56c6c",
|
||||||
|
"bgColor": "rgba(245, 108, 108, 0.1)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# 2. 30天学习进度趋势
|
||||||
|
thirty_days_ago = datetime.now() - timedelta(days=30)
|
||||||
|
dates = []
|
||||||
|
progress_data = []
|
||||||
|
|
||||||
|
for i in range(30):
|
||||||
|
date = thirty_days_ago + timedelta(days=i)
|
||||||
|
dates.append(date.strftime("%m-%d"))
|
||||||
|
|
||||||
|
# 统计该日期之前完成的考试数
|
||||||
|
cumulative_exams_stmt = select(func.count(Exam.id)).where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id == user.id,
|
||||||
|
Exam.created_at <= date,
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(cumulative_exams_stmt)
|
||||||
|
cumulative = result.scalar() or 0
|
||||||
|
|
||||||
|
# 进度 = 累计考试数 * 10(简化计算)
|
||||||
|
progress = min(cumulative * 10, 100)
|
||||||
|
progress_data.append(progress)
|
||||||
|
|
||||||
|
# 3. 能力评估(从陪练报告聚合)
|
||||||
|
ability_stmt = select(PracticeReport.ability_dimensions).where(
|
||||||
|
PracticeReport.user_id == user.id
|
||||||
|
)
|
||||||
|
result = await db.execute(ability_stmt)
|
||||||
|
all_dimensions = result.scalars().all()
|
||||||
|
|
||||||
|
abilities = []
|
||||||
|
if all_dimensions:
|
||||||
|
# 聚合能力数据
|
||||||
|
ability_scores: Dict[str, List[float]] = {}
|
||||||
|
|
||||||
|
for dimensions in all_dimensions:
|
||||||
|
if dimensions:
|
||||||
|
for dim in dimensions:
|
||||||
|
name = dim.get('name', '')
|
||||||
|
score = dim.get('score', 0)
|
||||||
|
if name:
|
||||||
|
if name not in ability_scores:
|
||||||
|
ability_scores[name] = []
|
||||||
|
ability_scores[name].append(float(score))
|
||||||
|
|
||||||
|
# 计算平均分
|
||||||
|
for name, scores in ability_scores.items():
|
||||||
|
avg = sum(scores) / len(scores)
|
||||||
|
description = "表现良好" if avg >= 80 else "需要加强"
|
||||||
|
abilities.append({
|
||||||
|
"name": name,
|
||||||
|
"score": int(avg),
|
||||||
|
"description": description
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# 默认能力评估
|
||||||
|
default_abilities = [
|
||||||
|
{"name": "沟通表达", "score": 0, "description": "暂无数据"},
|
||||||
|
{"name": "需求挖掘", "score": 0, "description": "暂无数据"},
|
||||||
|
{"name": "产品知识", "score": 0, "description": "暂无数据"},
|
||||||
|
{"name": "成交技巧", "score": 0, "description": "暂无数据"}
|
||||||
|
]
|
||||||
|
abilities = default_abilities
|
||||||
|
|
||||||
|
# 4. 详细学习记录(最近20条)
|
||||||
|
records = []
|
||||||
|
|
||||||
|
# 考试记录
|
||||||
|
exam_records_stmt = (
|
||||||
|
select(Exam, Course.name.label('course_name'))
|
||||||
|
.join(Course, Course.id == Exam.course_id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Exam.user_id == user.id,
|
||||||
|
Exam.status.in_(['completed', 'submitted'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Exam.updated_at.desc())
|
||||||
|
.limit(20)
|
||||||
|
)
|
||||||
|
result = await db.execute(exam_records_stmt)
|
||||||
|
exam_records = result.all()
|
||||||
|
|
||||||
|
for exam, course_name in exam_records:
|
||||||
|
score = exam.round1_score or 0
|
||||||
|
records.append({
|
||||||
|
"date": exam.updated_at.strftime("%Y-%m-%d"),
|
||||||
|
"course": course_name,
|
||||||
|
"duration": exam.duration_minutes or 0,
|
||||||
|
"score": int(score),
|
||||||
|
"status": "completed"
|
||||||
|
})
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"overview": overview,
|
||||||
|
"progressTrend": {
|
||||||
|
"dates": dates,
|
||||||
|
"data": progress_data
|
||||||
|
},
|
||||||
|
"abilities": abilities,
|
||||||
|
"records": records[:20]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseModel(code=200, message="success", data=data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取成员学习报告失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(
|
||||||
|
code=500,
|
||||||
|
message=f"获取成员学习报告失败: {str(e)}",
|
||||||
|
data=None
|
||||||
|
)
|
||||||
|
|
||||||
55
backend/app/api/v1/teams.py
Normal file
55
backend/app/api/v1/teams.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
团队相关 API 路由
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_current_active_user as get_current_user, get_db
|
||||||
|
from app.core.logger import logger
|
||||||
|
from app.models.user import Team
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/teams", tags=["teams"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=ResponseModel)
|
||||||
|
async def list_teams(
|
||||||
|
keyword: Optional[str] = Query(None, description="按名称或编码模糊搜索"),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取团队列表
|
||||||
|
|
||||||
|
任何登录用户均可查询团队列表,用于前端下拉选择。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stmt = select(Team).where(Team.is_deleted == False) # noqa: E712
|
||||||
|
if keyword:
|
||||||
|
like = f"%{keyword}%"
|
||||||
|
stmt = stmt.where(or_(Team.name.ilike(like), Team.code.ilike(like)))
|
||||||
|
|
||||||
|
rows: List[Team] = (await db.execute(stmt)).scalars().all()
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"id": t.id,
|
||||||
|
"name": t.name,
|
||||||
|
"code": t.code,
|
||||||
|
"team_type": t.team_type,
|
||||||
|
}
|
||||||
|
for t in rows
|
||||||
|
]
|
||||||
|
return ResponseModel(code=200, message="OK", data=data)
|
||||||
|
except Exception:
|
||||||
|
logger.error("查询团队列表失败", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="查询团队列表失败",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
507
backend/app/api/v1/training.py
Normal file
507
backend/app/api/v1/training.py
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
"""陪练模块API路由"""
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_db, get_current_user, require_admin
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
from app.schemas.training import (
|
||||||
|
TrainingSceneCreate,
|
||||||
|
TrainingSceneUpdate,
|
||||||
|
TrainingSceneResponse,
|
||||||
|
TrainingSessionResponse,
|
||||||
|
TrainingMessageResponse,
|
||||||
|
TrainingReportResponse,
|
||||||
|
StartTrainingRequest,
|
||||||
|
StartTrainingResponse,
|
||||||
|
EndTrainingRequest,
|
||||||
|
EndTrainingResponse,
|
||||||
|
TrainingSceneListQuery,
|
||||||
|
TrainingSessionListQuery,
|
||||||
|
PaginatedResponse,
|
||||||
|
)
|
||||||
|
from app.services.training_service import (
|
||||||
|
TrainingSceneService,
|
||||||
|
TrainingSessionService,
|
||||||
|
TrainingMessageService,
|
||||||
|
TrainingReportService,
|
||||||
|
)
|
||||||
|
from app.models.training import TrainingSceneStatus, TrainingSessionStatus
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/training", tags=["陪练模块"])
|
||||||
|
|
||||||
|
# 服务实例
|
||||||
|
scene_service = TrainingSceneService()
|
||||||
|
session_service = TrainingSessionService()
|
||||||
|
message_service = TrainingMessageService()
|
||||||
|
report_service = TrainingReportService()
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 陪练场景管理 ==========
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/scenes", response_model=ResponseModel[PaginatedResponse[TrainingSceneResponse]]
|
||||||
|
)
|
||||||
|
async def get_training_scenes(
|
||||||
|
category: Optional[str] = Query(None, description="场景分类"),
|
||||||
|
status: Optional[TrainingSceneStatus] = Query(None, description="场景状态"),
|
||||||
|
is_public: Optional[bool] = Query(None, description="是否公开"),
|
||||||
|
search: Optional[str] = Query(None, description="搜索关键词"),
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取陪练场景列表
|
||||||
|
|
||||||
|
- 支持按分类、状态、是否公开筛选
|
||||||
|
- 支持关键词搜索
|
||||||
|
- 支持分页
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 计算分页参数
|
||||||
|
skip = (page - 1) * page_size
|
||||||
|
|
||||||
|
# 获取用户等级(TODO: 从User服务获取)
|
||||||
|
user_level = 1
|
||||||
|
|
||||||
|
# 获取场景列表
|
||||||
|
scenes = await scene_service.get_active_scenes(
|
||||||
|
db,
|
||||||
|
category=category,
|
||||||
|
is_public=is_public,
|
||||||
|
user_level=user_level,
|
||||||
|
skip=skip,
|
||||||
|
limit=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取总数
|
||||||
|
from sqlalchemy import select, func, and_
|
||||||
|
from app.models.training import TrainingScene
|
||||||
|
|
||||||
|
count_query = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(TrainingScene)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
TrainingScene.status == TrainingSceneStatus.ACTIVE,
|
||||||
|
TrainingScene.is_deleted == False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
count_query = count_query.where(TrainingScene.category == category)
|
||||||
|
if is_public is not None:
|
||||||
|
count_query = count_query.where(TrainingScene.is_public == is_public)
|
||||||
|
|
||||||
|
result = await db.execute(count_query)
|
||||||
|
total = result.scalar_one()
|
||||||
|
|
||||||
|
# 计算总页数
|
||||||
|
pages = (total + page_size - 1) // page_size
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
data=PaginatedResponse(
|
||||||
|
items=scenes, total=total, page=page, page_size=page_size, pages=pages
|
||||||
|
),
|
||||||
|
message="获取陪练场景列表成功",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取陪练场景列表失败: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="获取陪练场景列表失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scenes/{scene_id}", response_model=ResponseModel[TrainingSceneResponse])
|
||||||
|
async def get_training_scene(
|
||||||
|
scene_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取陪练场景详情"""
|
||||||
|
scene = await scene_service.get(db, scene_id)
|
||||||
|
|
||||||
|
if not scene or scene.is_deleted:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练场景不存在")
|
||||||
|
|
||||||
|
# 检查访问权限
|
||||||
|
if not scene.is_public and current_user.get("role") != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此场景")
|
||||||
|
|
||||||
|
return ResponseModel(data=scene, message="获取陪练场景成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scenes", response_model=ResponseModel[TrainingSceneResponse])
|
||||||
|
async def create_training_scene(
|
||||||
|
scene_in: TrainingSceneCreate,
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
创建陪练场景(管理员)
|
||||||
|
|
||||||
|
- 需要管理员权限
|
||||||
|
- 场景默认为草稿状态
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
scene = await scene_service.create_scene(
|
||||||
|
db, scene_in=scene_in, created_by=current_user["id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"管理员 {current_user['id']} 创建了陪练场景: {scene.id}")
|
||||||
|
|
||||||
|
return ResponseModel(data=scene, message="创建陪练场景成功")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建陪练场景失败: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="创建陪练场景失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/scenes/{scene_id}", response_model=ResponseModel[TrainingSceneResponse])
|
||||||
|
async def update_training_scene(
|
||||||
|
scene_id: int,
|
||||||
|
scene_in: TrainingSceneUpdate,
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""更新陪练场景(管理员)"""
|
||||||
|
scene = await scene_service.update_scene(
|
||||||
|
db, scene_id=scene_id, scene_in=scene_in, updated_by=current_user["id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not scene:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练场景不存在")
|
||||||
|
|
||||||
|
logger.info(f"管理员 {current_user['id']} 更新了陪练场景: {scene_id}")
|
||||||
|
|
||||||
|
return ResponseModel(data=scene, message="更新陪练场景成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/scenes/{scene_id}", response_model=ResponseModel[bool])
|
||||||
|
async def delete_training_scene(
|
||||||
|
scene_id: int,
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""删除陪练场景(管理员)"""
|
||||||
|
success = await scene_service.soft_delete(db, id=scene_id)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练场景不存在")
|
||||||
|
|
||||||
|
logger.info(f"管理员 {current_user['id']} 删除了陪练场景: {scene_id}")
|
||||||
|
|
||||||
|
return ResponseModel(data=True, message="删除陪练场景成功")
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 陪练会话管理 ==========
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions", response_model=ResponseModel[StartTrainingResponse])
|
||||||
|
async def start_training(
|
||||||
|
request: StartTrainingRequest,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
开始陪练会话
|
||||||
|
|
||||||
|
- 需要登录
|
||||||
|
- 创建会话记录
|
||||||
|
- 初始化Coze对话(如果配置了Bot)
|
||||||
|
- 返回会话信息和WebSocket连接地址(如果支持)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await session_service.start_training(
|
||||||
|
db, request=request, user_id=current_user["id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"用户 {current_user['id']} 开始陪练会话: {response.session_id}")
|
||||||
|
|
||||||
|
return ResponseModel(data=response, message="开始陪练成功")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"开始陪练失败: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="开始陪练失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sessions/{session_id}/end", response_model=ResponseModel[EndTrainingResponse]
|
||||||
|
)
|
||||||
|
async def end_training(
|
||||||
|
session_id: int,
|
||||||
|
request: EndTrainingRequest,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
结束陪练会话
|
||||||
|
|
||||||
|
- 需要登录且是会话创建者
|
||||||
|
- 更新会话状态
|
||||||
|
- 可选生成陪练报告
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await session_service.end_training(
|
||||||
|
db, session_id=session_id, request=request, user_id=current_user["id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"用户 {current_user['id']} 结束陪练会话: {session_id}")
|
||||||
|
|
||||||
|
return ResponseModel(data=response, message="结束陪练成功")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"结束陪练失败: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="结束陪练失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sessions",
|
||||||
|
response_model=ResponseModel[PaginatedResponse[TrainingSessionResponse]],
|
||||||
|
)
|
||||||
|
async def get_training_sessions(
|
||||||
|
scene_id: Optional[int] = Query(None, description="场景ID"),
|
||||||
|
status: Optional[TrainingSessionStatus] = Query(None, description="会话状态"),
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取用户的陪练会话列表"""
|
||||||
|
try:
|
||||||
|
skip = (page - 1) * page_size
|
||||||
|
|
||||||
|
sessions = await session_service.get_user_sessions(
|
||||||
|
db,
|
||||||
|
user_id=current_user["id"],
|
||||||
|
scene_id=scene_id,
|
||||||
|
status=status,
|
||||||
|
skip=skip,
|
||||||
|
limit=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取总数
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from app.models.training import TrainingSession
|
||||||
|
|
||||||
|
count_query = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(TrainingSession)
|
||||||
|
.where(TrainingSession.user_id == current_user["id"])
|
||||||
|
)
|
||||||
|
|
||||||
|
if scene_id:
|
||||||
|
count_query = count_query.where(TrainingSession.scene_id == scene_id)
|
||||||
|
if status:
|
||||||
|
count_query = count_query.where(TrainingSession.status == status)
|
||||||
|
|
||||||
|
result = await db.execute(count_query)
|
||||||
|
total = result.scalar_one()
|
||||||
|
|
||||||
|
pages = (total + page_size - 1) // page_size
|
||||||
|
|
||||||
|
# 加载关联的场景信息
|
||||||
|
for session in sessions:
|
||||||
|
await db.refresh(session, ["scene"])
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
data=PaginatedResponse(
|
||||||
|
items=sessions, total=total, page=page, page_size=page_size, pages=pages
|
||||||
|
),
|
||||||
|
message="获取陪练会话列表成功",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取陪练会话列表失败: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="获取陪练会话列表失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sessions/{session_id}", response_model=ResponseModel[TrainingSessionResponse]
|
||||||
|
)
|
||||||
|
async def get_training_session(
|
||||||
|
session_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取陪练会话详情"""
|
||||||
|
session = await session_service.get(db, session_id)
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练会话不存在")
|
||||||
|
|
||||||
|
# 检查访问权限
|
||||||
|
if session.user_id != current_user["id"] and current_user.get("role") != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此会话")
|
||||||
|
|
||||||
|
# 加载关联数据
|
||||||
|
await db.refresh(session, ["scene"])
|
||||||
|
|
||||||
|
# 获取消息数量
|
||||||
|
messages = await message_service.get_session_messages(db, session_id=session_id)
|
||||||
|
session.message_count = len(messages)
|
||||||
|
|
||||||
|
return ResponseModel(data=session, message="获取陪练会话成功")
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 消息管理 ==========
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sessions/{session_id}/messages",
|
||||||
|
response_model=ResponseModel[List[TrainingMessageResponse]],
|
||||||
|
)
|
||||||
|
async def get_training_messages(
|
||||||
|
session_id: int,
|
||||||
|
skip: int = Query(0, ge=0, description="跳过数量"),
|
||||||
|
limit: int = Query(100, ge=1, le=500, description="返回数量"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取陪练会话的消息列表"""
|
||||||
|
# 验证会话访问权限
|
||||||
|
session = await session_service.get(db, session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练会话不存在")
|
||||||
|
|
||||||
|
if session.user_id != current_user["id"] and current_user.get("role") != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此会话消息")
|
||||||
|
|
||||||
|
messages = await message_service.get_session_messages(
|
||||||
|
db, session_id=session_id, skip=skip, limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=messages, message="获取消息列表成功")
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 报告管理 ==========
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/reports", response_model=ResponseModel[PaginatedResponse[TrainingReportResponse]]
|
||||||
|
)
|
||||||
|
async def get_training_reports(
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取用户的陪练报告列表"""
|
||||||
|
try:
|
||||||
|
skip = (page - 1) * page_size
|
||||||
|
|
||||||
|
reports = await report_service.get_user_reports(
|
||||||
|
db, user_id=current_user["id"], skip=skip, limit=page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取总数
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from app.models.training import TrainingReport
|
||||||
|
|
||||||
|
count_query = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(TrainingReport)
|
||||||
|
.where(TrainingReport.user_id == current_user["id"])
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(count_query)
|
||||||
|
total = result.scalar_one()
|
||||||
|
|
||||||
|
pages = (total + page_size - 1) // page_size
|
||||||
|
|
||||||
|
# 加载关联的会话信息
|
||||||
|
for report in reports:
|
||||||
|
await db.refresh(report, ["session"])
|
||||||
|
if report.session:
|
||||||
|
await db.refresh(report.session, ["scene"])
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
data=PaginatedResponse(
|
||||||
|
items=reports, total=total, page=page, page_size=page_size, pages=pages
|
||||||
|
),
|
||||||
|
message="获取陪练报告列表成功",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取陪练报告列表失败: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="获取陪练报告列表失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/reports/{report_id}", response_model=ResponseModel[TrainingReportResponse]
|
||||||
|
)
|
||||||
|
async def get_training_report(
|
||||||
|
report_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取陪练报告详情"""
|
||||||
|
report = await report_service.get(db, report_id)
|
||||||
|
|
||||||
|
if not report:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练报告不存在")
|
||||||
|
|
||||||
|
# 检查访问权限
|
||||||
|
if report.user_id != current_user["id"] and current_user.get("role") != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此报告")
|
||||||
|
|
||||||
|
# 加载关联数据
|
||||||
|
await db.refresh(report, ["session"])
|
||||||
|
if report.session:
|
||||||
|
await db.refresh(report.session, ["scene"])
|
||||||
|
|
||||||
|
return ResponseModel(data=report, message="获取陪练报告成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sessions/{session_id}/report",
|
||||||
|
response_model=ResponseModel[TrainingReportResponse],
|
||||||
|
)
|
||||||
|
async def get_session_report(
|
||||||
|
session_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""根据会话ID获取陪练报告"""
|
||||||
|
# 验证会话访问权限
|
||||||
|
session = await session_service.get(db, session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练会话不存在")
|
||||||
|
|
||||||
|
if session.user_id != current_user["id"] and current_user.get("role") != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此会话报告")
|
||||||
|
|
||||||
|
# 获取报告
|
||||||
|
report = await report_service.get_by_session(db, session_id=session_id)
|
||||||
|
|
||||||
|
if not report:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="该会话暂无报告")
|
||||||
|
|
||||||
|
# 加载关联数据
|
||||||
|
await db.refresh(report, ["session"])
|
||||||
|
if report.session:
|
||||||
|
await db.refresh(report.session, ["scene"])
|
||||||
|
|
||||||
|
return ResponseModel(data=report, message="获取会话报告成功")
|
||||||
854
backend/app/api/v1/training_api_contract.yaml
Normal file
854
backend/app/api/v1/training_api_contract.yaml
Normal file
@@ -0,0 +1,854 @@
|
|||||||
|
openapi: 3.0.0
|
||||||
|
info:
|
||||||
|
title: Training Module API
|
||||||
|
description: 考培练系统陪练模块API契约
|
||||||
|
version: 1.0.0
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8000/api/v1
|
||||||
|
description: 本地开发服务器
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/training/scenes:
|
||||||
|
get:
|
||||||
|
summary: 获取陪练场景列表
|
||||||
|
tags:
|
||||||
|
- 陪练场景
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: category
|
||||||
|
in: query
|
||||||
|
description: 场景分类
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
description: 场景状态
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [draft, active, inactive]
|
||||||
|
- name: is_public
|
||||||
|
in: query
|
||||||
|
description: 是否公开
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
- name: search
|
||||||
|
in: query
|
||||||
|
description: 搜索关键词
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
description: 页码
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
default: 1
|
||||||
|
- name: page_size
|
||||||
|
in: query
|
||||||
|
description: 每页数量
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
default: 20
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PaginatedScenesResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
|
||||||
|
post:
|
||||||
|
summary: 创建陪练场景(管理员)
|
||||||
|
tags:
|
||||||
|
- 陪练场景
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TrainingSceneCreate'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TrainingSceneResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
|
||||||
|
/training/scenes/{scene_id}:
|
||||||
|
get:
|
||||||
|
summary: 获取陪练场景详情
|
||||||
|
tags:
|
||||||
|
- 陪练场景
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: scene_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 场景ID
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TrainingSceneResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
put:
|
||||||
|
summary: 更新陪练场景(管理员)
|
||||||
|
tags:
|
||||||
|
- 陪练场景
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: scene_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 场景ID
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TrainingSceneUpdate'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TrainingSceneResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
delete:
|
||||||
|
summary: 删除陪练场景(管理员)
|
||||||
|
tags:
|
||||||
|
- 陪练场景
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: scene_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 场景ID
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: integer
|
||||||
|
example: 200
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: "删除陪练场景成功"
|
||||||
|
data:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
/training/sessions:
|
||||||
|
post:
|
||||||
|
summary: 开始陪练会话
|
||||||
|
tags:
|
||||||
|
- 陪练会话
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/StartTrainingRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/StartTrainingResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'404':
|
||||||
|
description: 场景不存在
|
||||||
|
|
||||||
|
get:
|
||||||
|
summary: 获取用户的陪练会话列表
|
||||||
|
tags:
|
||||||
|
- 陪练会话
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: scene_id
|
||||||
|
in: query
|
||||||
|
description: 场景ID
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
description: 会话状态
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [created, in_progress, completed, cancelled, error]
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
default: 1
|
||||||
|
- name: page_size
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
default: 20
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PaginatedSessionsResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
|
||||||
|
/training/sessions/{session_id}:
|
||||||
|
get:
|
||||||
|
summary: 获取陪练会话详情
|
||||||
|
tags:
|
||||||
|
- 陪练会话
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: session_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 会话ID
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TrainingSessionResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
/training/sessions/{session_id}/end:
|
||||||
|
post:
|
||||||
|
summary: 结束陪练会话
|
||||||
|
tags:
|
||||||
|
- 陪练会话
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: session_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 会话ID
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/EndTrainingRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/EndTrainingResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
/training/sessions/{session_id}/messages:
|
||||||
|
get:
|
||||||
|
summary: 获取陪练会话的消息列表
|
||||||
|
tags:
|
||||||
|
- 陪练消息
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: session_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 会话ID
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: skip
|
||||||
|
in: query
|
||||||
|
description: 跳过数量
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
default: 0
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
description: 返回数量
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 500
|
||||||
|
default: 100
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: integer
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/TrainingMessage'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
/training/reports:
|
||||||
|
get:
|
||||||
|
summary: 获取用户的陪练报告列表
|
||||||
|
tags:
|
||||||
|
- 陪练报告
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
default: 1
|
||||||
|
- name: page_size
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
default: 20
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PaginatedReportsResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
|
||||||
|
/training/reports/{report_id}:
|
||||||
|
get:
|
||||||
|
summary: 获取陪练报告详情
|
||||||
|
tags:
|
||||||
|
- 陪练报告
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: report_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 报告ID
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TrainingReportResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
/training/sessions/{session_id}/report:
|
||||||
|
get:
|
||||||
|
summary: 根据会话ID获取陪练报告
|
||||||
|
tags:
|
||||||
|
- 陪练报告
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: session_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 会话ID
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TrainingReportResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
|
||||||
|
responses:
|
||||||
|
Unauthorized:
|
||||||
|
description: 未授权
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
Forbidden:
|
||||||
|
description: 禁止访问
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
NotFound:
|
||||||
|
description: 资源未找到
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: integer
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
detail:
|
||||||
|
type: object
|
||||||
|
|
||||||
|
BaseResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: integer
|
||||||
|
example: 200
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: "success"
|
||||||
|
request_id:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
PaginationMeta:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
page:
|
||||||
|
type: integer
|
||||||
|
page_size:
|
||||||
|
type: integer
|
||||||
|
pages:
|
||||||
|
type: integer
|
||||||
|
|
||||||
|
TrainingSceneCreate:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- category
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
maxLength: 100
|
||||||
|
description: 场景名称
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: 场景描述
|
||||||
|
category:
|
||||||
|
type: string
|
||||||
|
maxLength: 50
|
||||||
|
description: 场景分类
|
||||||
|
ai_config:
|
||||||
|
type: object
|
||||||
|
description: AI配置
|
||||||
|
prompt_template:
|
||||||
|
type: string
|
||||||
|
description: 提示词模板
|
||||||
|
evaluation_criteria:
|
||||||
|
type: object
|
||||||
|
description: 评估标准
|
||||||
|
is_public:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
description: 是否公开
|
||||||
|
required_level:
|
||||||
|
type: integer
|
||||||
|
description: 所需用户等级
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [draft, active, inactive]
|
||||||
|
default: draft
|
||||||
|
|
||||||
|
TrainingSceneUpdate:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
maxLength: 100
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
category:
|
||||||
|
type: string
|
||||||
|
maxLength: 50
|
||||||
|
ai_config:
|
||||||
|
type: object
|
||||||
|
prompt_template:
|
||||||
|
type: string
|
||||||
|
evaluation_criteria:
|
||||||
|
type: object
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [draft, active, inactive]
|
||||||
|
is_public:
|
||||||
|
type: boolean
|
||||||
|
required_level:
|
||||||
|
type: integer
|
||||||
|
|
||||||
|
TrainingScene:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
category:
|
||||||
|
type: string
|
||||||
|
ai_config:
|
||||||
|
type: object
|
||||||
|
prompt_template:
|
||||||
|
type: string
|
||||||
|
evaluation_criteria:
|
||||||
|
type: object
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [draft, active, inactive]
|
||||||
|
is_public:
|
||||||
|
type: boolean
|
||||||
|
required_level:
|
||||||
|
type: integer
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
TrainingSceneResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/BaseResponse'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/TrainingScene'
|
||||||
|
|
||||||
|
PaginatedScenesResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/BaseResponse'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/TrainingScene'
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
page:
|
||||||
|
type: integer
|
||||||
|
page_size:
|
||||||
|
type: integer
|
||||||
|
pages:
|
||||||
|
type: integer
|
||||||
|
|
||||||
|
StartTrainingRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- scene_id
|
||||||
|
properties:
|
||||||
|
scene_id:
|
||||||
|
type: integer
|
||||||
|
description: 场景ID
|
||||||
|
config:
|
||||||
|
type: object
|
||||||
|
description: 会话配置
|
||||||
|
|
||||||
|
StartTrainingResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/BaseResponse'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
session_id:
|
||||||
|
type: integer
|
||||||
|
coze_conversation_id:
|
||||||
|
type: string
|
||||||
|
scene:
|
||||||
|
$ref: '#/components/schemas/TrainingScene'
|
||||||
|
websocket_url:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
EndTrainingRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
generate_report:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
description: 是否生成报告
|
||||||
|
|
||||||
|
EndTrainingResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/BaseResponse'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
session:
|
||||||
|
$ref: '#/components/schemas/TrainingSession'
|
||||||
|
report:
|
||||||
|
$ref: '#/components/schemas/TrainingReport'
|
||||||
|
|
||||||
|
TrainingSession:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
user_id:
|
||||||
|
type: integer
|
||||||
|
scene_id:
|
||||||
|
type: integer
|
||||||
|
coze_conversation_id:
|
||||||
|
type: string
|
||||||
|
start_time:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
end_time:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
duration_seconds:
|
||||||
|
type: integer
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [created, in_progress, completed, cancelled, error]
|
||||||
|
session_config:
|
||||||
|
type: object
|
||||||
|
total_score:
|
||||||
|
type: number
|
||||||
|
evaluation_result:
|
||||||
|
type: object
|
||||||
|
scene:
|
||||||
|
$ref: '#/components/schemas/TrainingScene'
|
||||||
|
message_count:
|
||||||
|
type: integer
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
TrainingSessionResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/BaseResponse'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/TrainingSession'
|
||||||
|
|
||||||
|
PaginatedSessionsResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/BaseResponse'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/TrainingSession'
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
page:
|
||||||
|
type: integer
|
||||||
|
page_size:
|
||||||
|
type: integer
|
||||||
|
pages:
|
||||||
|
type: integer
|
||||||
|
|
||||||
|
TrainingMessage:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
session_id:
|
||||||
|
type: integer
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum: [user, assistant, system]
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [text, voice, system]
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
voice_url:
|
||||||
|
type: string
|
||||||
|
voice_duration:
|
||||||
|
type: number
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
coze_message_id:
|
||||||
|
type: string
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
TrainingReport:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
session_id:
|
||||||
|
type: integer
|
||||||
|
user_id:
|
||||||
|
type: integer
|
||||||
|
overall_score:
|
||||||
|
type: number
|
||||||
|
dimension_scores:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: number
|
||||||
|
strengths:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
weaknesses:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
suggestions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
detailed_analysis:
|
||||||
|
type: string
|
||||||
|
transcript:
|
||||||
|
type: string
|
||||||
|
statistics:
|
||||||
|
type: object
|
||||||
|
session:
|
||||||
|
$ref: '#/components/schemas/TrainingSession'
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
TrainingReportResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/BaseResponse'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/TrainingReport'
|
||||||
|
|
||||||
|
PaginatedReportsResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/BaseResponse'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/TrainingReport'
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
page:
|
||||||
|
type: integer
|
||||||
|
page_size:
|
||||||
|
type: integer
|
||||||
|
pages:
|
||||||
|
type: integer
|
||||||
275
backend/app/api/v1/upload.py
Normal file
275
backend/app/api/v1/upload.py
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
"""
|
||||||
|
文件上传API接口
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.deps import get_current_user, get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.course import Course
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/upload")
|
||||||
|
|
||||||
|
# 支持的文件类型和大小限制
|
||||||
|
# 支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties
|
||||||
|
ALLOWED_EXTENSIONS = {
|
||||||
|
'txt', 'md', 'mdx', 'pdf', 'html', 'htm',
|
||||||
|
'xlsx', 'xls', 'docx', 'doc', 'csv', 'vtt', 'properties'
|
||||||
|
}
|
||||||
|
MAX_FILE_SIZE = 15 * 1024 * 1024 # 15MB
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_extension(filename: str) -> str:
|
||||||
|
"""获取文件扩展名"""
|
||||||
|
return filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||||
|
|
||||||
|
|
||||||
|
def generate_unique_filename(original_filename: str) -> str:
|
||||||
|
"""生成唯一的文件名"""
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||||
|
random_str = hashlib.md5(f"{original_filename}{timestamp}".encode()).hexdigest()[:8]
|
||||||
|
ext = get_file_extension(original_filename)
|
||||||
|
return f"{timestamp}_{random_str}.{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_upload_path(file_type: str = "general") -> Path:
|
||||||
|
"""获取上传路径"""
|
||||||
|
base_path = Path(settings.UPLOAD_PATH)
|
||||||
|
upload_path = base_path / file_type
|
||||||
|
upload_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return upload_path
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/file", response_model=ResponseModel[dict])
|
||||||
|
async def upload_file(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
file_type: str = "general",
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
上传单个文件
|
||||||
|
|
||||||
|
- **file**: 要上传的文件
|
||||||
|
- **file_type**: 文件类型分类(general, course, avatar等)
|
||||||
|
|
||||||
|
返回:
|
||||||
|
- **file_url**: 文件访问URL
|
||||||
|
- **file_name**: 原始文件名
|
||||||
|
- **file_size**: 文件大小
|
||||||
|
- **file_type**: 文件类型
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 检查文件扩展名
|
||||||
|
file_ext = get_file_extension(file.filename)
|
||||||
|
if file_ext not in ALLOWED_EXTENSIONS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"不支持的文件类型: {file_ext}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 读取文件内容
|
||||||
|
contents = await file.read()
|
||||||
|
file_size = len(contents)
|
||||||
|
|
||||||
|
# 检查文件大小
|
||||||
|
if file_size > MAX_FILE_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"文件大小超过限制,最大允许 {MAX_FILE_SIZE // 1024 // 1024}MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 生成唯一文件名
|
||||||
|
unique_filename = generate_unique_filename(file.filename)
|
||||||
|
|
||||||
|
# 获取上传路径
|
||||||
|
upload_path = get_upload_path(file_type)
|
||||||
|
file_path = upload_path / unique_filename
|
||||||
|
|
||||||
|
# 保存文件
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(contents)
|
||||||
|
|
||||||
|
# 生成文件访问URL
|
||||||
|
file_url = f"/static/uploads/{file_type}/{unique_filename}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"文件上传成功",
|
||||||
|
user_id=current_user.id,
|
||||||
|
original_filename=file.filename,
|
||||||
|
saved_filename=unique_filename,
|
||||||
|
file_size=file_size,
|
||||||
|
file_type=file_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
data={
|
||||||
|
"file_url": file_url,
|
||||||
|
"file_name": file.filename,
|
||||||
|
"file_size": file_size,
|
||||||
|
"file_type": file_ext,
|
||||||
|
},
|
||||||
|
message="文件上传成功"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"文件上传失败: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="文件上传失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/course/{course_id}/materials", response_model=ResponseModel[dict])
|
||||||
|
async def upload_course_material(
|
||||||
|
course_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
上传课程资料
|
||||||
|
|
||||||
|
- **course_id**: 课程ID
|
||||||
|
- **file**: 要上传的文件
|
||||||
|
|
||||||
|
返回上传结果,包含文件URL等信息
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 验证课程是否存在
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.course import Course
|
||||||
|
|
||||||
|
stmt = select(Course).where(Course.id == course_id, Course.is_deleted == False)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
course = result.scalar_one_or_none()
|
||||||
|
if not course:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"课程 {course_id} 不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查文件扩展名
|
||||||
|
file_ext = get_file_extension(file.filename)
|
||||||
|
if file_ext not in ALLOWED_EXTENSIONS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"不支持的文件类型: {file_ext}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 读取文件内容
|
||||||
|
contents = await file.read()
|
||||||
|
file_size = len(contents)
|
||||||
|
|
||||||
|
# 检查文件大小
|
||||||
|
if file_size > MAX_FILE_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"文件大小超过限制,最大允许 {MAX_FILE_SIZE // 1024 // 1024}MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 生成唯一文件名
|
||||||
|
unique_filename = generate_unique_filename(file.filename)
|
||||||
|
|
||||||
|
# 创建课程专属目录
|
||||||
|
course_upload_path = Path(settings.UPLOAD_PATH) / "courses" / str(course_id)
|
||||||
|
course_upload_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 保存文件
|
||||||
|
file_path = course_upload_path / unique_filename
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(contents)
|
||||||
|
|
||||||
|
# 生成文件访问URL
|
||||||
|
file_url = f"/static/uploads/courses/{course_id}/{unique_filename}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"课程资料上传成功",
|
||||||
|
user_id=current_user.id,
|
||||||
|
course_id=course_id,
|
||||||
|
original_filename=file.filename,
|
||||||
|
saved_filename=unique_filename,
|
||||||
|
file_size=file_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
data={
|
||||||
|
"file_url": file_url,
|
||||||
|
"file_name": file.filename,
|
||||||
|
"file_size": file_size,
|
||||||
|
"file_type": file_ext,
|
||||||
|
},
|
||||||
|
message="课程资料上传成功"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"课程资料上传失败: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="课程资料上传失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/file", response_model=ResponseModel[bool])
|
||||||
|
async def delete_file(
|
||||||
|
file_url: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除已上传的文件
|
||||||
|
|
||||||
|
- **file_url**: 文件URL路径
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 解析文件路径
|
||||||
|
if not file_url.startswith("/static/uploads/"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="无效的文件URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 转换为实际文件路径
|
||||||
|
relative_path = file_url.replace("/static/uploads/", "")
|
||||||
|
file_path = Path(settings.UPLOAD_PATH) / relative_path
|
||||||
|
|
||||||
|
# 检查文件是否存在
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="文件不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 删除文件
|
||||||
|
os.remove(file_path)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"文件删除成功",
|
||||||
|
user_id=current_user.id,
|
||||||
|
file_url=file_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=True, message="文件删除成功")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"文件删除失败: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="文件删除失败"
|
||||||
|
)
|
||||||
474
backend/app/api/v1/users.py
Normal file
474
backend/app/api/v1/users.py
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
"""
|
||||||
|
用户管理 API
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_current_active_user, get_db, require_admin
|
||||||
|
from app.core.logger import logger
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.base import PaginatedResponse, PaginationParams, ResponseModel
|
||||||
|
from app.schemas.user import User as UserSchema
|
||||||
|
from app.schemas.user import UserCreate, UserFilter, UserPasswordUpdate, UserUpdate
|
||||||
|
from app.services.user_service import UserService
|
||||||
|
from app.services.system_log_service import system_log_service
|
||||||
|
from app.schemas.system_log import SystemLogCreate
|
||||||
|
from app.models.exam import Exam, ExamResult
|
||||||
|
from app.models.training import TrainingSession
|
||||||
|
from app.models.position_member import PositionMember
|
||||||
|
from app.models.position import Position
|
||||||
|
from app.models.course import Course
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=ResponseModel)
|
||||||
|
async def get_current_user_info(
|
||||||
|
current_user: dict = Depends(get_current_active_user),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取当前用户信息
|
||||||
|
|
||||||
|
权限:需要登录
|
||||||
|
"""
|
||||||
|
return ResponseModel(data=UserSchema.model_validate(current_user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/statistics", response_model=ResponseModel)
|
||||||
|
async def get_current_user_statistics(
|
||||||
|
current_user: dict = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取当前用户学习统计
|
||||||
|
|
||||||
|
返回字段:
|
||||||
|
- learningDays: 学习天数(按陪练会话开始日期去重)
|
||||||
|
- totalHours: 学习总时长(小时,取整到1位小数)
|
||||||
|
- practiceQuestions: 练习题数(答题记录条数汇总)
|
||||||
|
- averageScore: 平均成绩(已提交考试的平均分,保留1位小数)
|
||||||
|
- examsCompleted: 已完成考试数量
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user_id = current_user.id
|
||||||
|
|
||||||
|
# 学习天数:按会话开始日期去重
|
||||||
|
learning_days_stmt = select(func.count(func.distinct(func.date(TrainingSession.start_time)))).where(
|
||||||
|
TrainingSession.user_id == user_id
|
||||||
|
)
|
||||||
|
learning_days = (await db.scalar(learning_days_stmt)) or 0
|
||||||
|
|
||||||
|
# 总时长(小时)
|
||||||
|
total_seconds_stmt = select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0)).where(
|
||||||
|
TrainingSession.user_id == user_id
|
||||||
|
)
|
||||||
|
total_seconds = (await db.scalar(total_seconds_stmt)) or 0
|
||||||
|
total_hours = round(float(total_seconds) / 3600.0, 1) if total_seconds else 0.0
|
||||||
|
|
||||||
|
# 练习题数:用户所有考试的题目总数
|
||||||
|
practice_questions_stmt = (
|
||||||
|
select(func.coalesce(func.sum(Exam.question_count), 0))
|
||||||
|
.where(Exam.user_id == user_id, Exam.status == "completed")
|
||||||
|
)
|
||||||
|
practice_questions = (await db.scalar(practice_questions_stmt)) or 0
|
||||||
|
|
||||||
|
# 平均成绩:用户已完成考试的平均分
|
||||||
|
avg_score_stmt = select(func.avg(Exam.score)).where(
|
||||||
|
Exam.user_id == user_id, Exam.status == "completed"
|
||||||
|
)
|
||||||
|
avg_score_val = await db.scalar(avg_score_stmt)
|
||||||
|
average_score = round(float(avg_score_val), 1) if avg_score_val is not None else 0.0
|
||||||
|
|
||||||
|
# 已完成考试数量
|
||||||
|
exams_completed_stmt = select(func.count(Exam.id)).where(
|
||||||
|
Exam.user_id == user_id,
|
||||||
|
Exam.status == "completed"
|
||||||
|
)
|
||||||
|
exams_completed = (await db.scalar(exams_completed_stmt)) or 0
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
data={
|
||||||
|
"learningDays": int(learning_days),
|
||||||
|
"totalHours": total_hours,
|
||||||
|
"practiceQuestions": int(practice_questions),
|
||||||
|
"averageScore": average_score,
|
||||||
|
"examsCompleted": int(exams_completed),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("获取用户学习统计失败", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取用户学习统计失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/recent-exams", response_model=ResponseModel)
|
||||||
|
async def get_recent_exams(
|
||||||
|
limit: int = Query(5, ge=1, le=20, description="返回数量"),
|
||||||
|
current_user: dict = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取当前用户最近的考试记录
|
||||||
|
|
||||||
|
返回最近的考试列表,按创建时间降序排列
|
||||||
|
只返回已完成或已提交的考试(不包括started状态)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user_id = current_user.id
|
||||||
|
|
||||||
|
# 查询最近的考试记录,关联课程表获取课程名称
|
||||||
|
stmt = (
|
||||||
|
select(Exam, Course.name.label("course_name"))
|
||||||
|
.join(Course, Exam.course_id == Course.id)
|
||||||
|
.where(
|
||||||
|
Exam.user_id == user_id,
|
||||||
|
Exam.status.in_(["completed", "submitted"])
|
||||||
|
)
|
||||||
|
.order_by(Exam.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await db.execute(stmt)
|
||||||
|
rows = results.all()
|
||||||
|
|
||||||
|
# 构建返回数据
|
||||||
|
exams_list = []
|
||||||
|
for exam, course_name in rows:
|
||||||
|
exams_list.append({
|
||||||
|
"id": exam.id,
|
||||||
|
"title": exam.exam_name,
|
||||||
|
"courseName": course_name,
|
||||||
|
"courseId": exam.course_id,
|
||||||
|
"time": exam.created_at.strftime("%Y-%m-%d %H:%M") if exam.created_at else "",
|
||||||
|
"questions": exam.question_count or 0,
|
||||||
|
"status": exam.status,
|
||||||
|
"score": exam.score
|
||||||
|
})
|
||||||
|
|
||||||
|
return ResponseModel(data=exams_list)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("获取最近考试记录失败", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取最近考试记录失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me", response_model=ResponseModel)
|
||||||
|
async def update_current_user(
|
||||||
|
user_in: UserUpdate,
|
||||||
|
current_user: dict = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
更新当前用户信息
|
||||||
|
|
||||||
|
权限:需要登录
|
||||||
|
"""
|
||||||
|
user_service = UserService(db)
|
||||||
|
user = await user_service.update_user(
|
||||||
|
user_id=current_user.id,
|
||||||
|
obj_in=user_in,
|
||||||
|
updated_by=current_user.id,
|
||||||
|
)
|
||||||
|
return ResponseModel(data=UserSchema.model_validate(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me/password", response_model=ResponseModel)
|
||||||
|
async def update_current_user_password(
|
||||||
|
password_in: UserPasswordUpdate,
|
||||||
|
current_user: dict = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
更新当前用户密码
|
||||||
|
|
||||||
|
权限:需要登录
|
||||||
|
"""
|
||||||
|
user_service = UserService(db)
|
||||||
|
user = await user_service.update_password(
|
||||||
|
user_id=current_user.id,
|
||||||
|
old_password=password_in.old_password,
|
||||||
|
new_password=password_in.new_password,
|
||||||
|
)
|
||||||
|
return ResponseModel(message="密码更新成功", data=UserSchema.model_validate(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=ResponseModel)
|
||||||
|
async def get_users(
|
||||||
|
pagination: PaginationParams = Depends(),
|
||||||
|
role: str = Query(None, description="用户角色"),
|
||||||
|
is_active: bool = Query(None, description="是否激活"),
|
||||||
|
team_id: int = Query(None, description="团队ID"),
|
||||||
|
keyword: str = Query(None, description="搜索关键词"),
|
||||||
|
current_user: dict = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取用户列表
|
||||||
|
|
||||||
|
权限:需要登录
|
||||||
|
- 普通用户只能看到激活的用户
|
||||||
|
- 管理员可以看到所有用户
|
||||||
|
"""
|
||||||
|
# 构建筛选条件
|
||||||
|
filter_params = UserFilter(
|
||||||
|
role=role,
|
||||||
|
is_active=is_active,
|
||||||
|
team_id=team_id,
|
||||||
|
keyword=keyword,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 普通用户只能看到激活的用户
|
||||||
|
if current_user.role == "trainee":
|
||||||
|
filter_params.is_active = True
|
||||||
|
|
||||||
|
# 获取用户列表
|
||||||
|
user_service = UserService(db)
|
||||||
|
users, total = await user_service.get_users_with_filter(
|
||||||
|
skip=pagination.offset,
|
||||||
|
limit=pagination.limit,
|
||||||
|
filter_params=filter_params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建分页响应
|
||||||
|
paginated = PaginatedResponse.create(
|
||||||
|
items=[UserSchema.model_validate(user) for user in users],
|
||||||
|
total=total,
|
||||||
|
page=pagination.page,
|
||||||
|
page_size=pagination.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=paginated.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=ResponseModel, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_user(
|
||||||
|
user_in: UserCreate,
|
||||||
|
request: Request,
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
创建用户
|
||||||
|
|
||||||
|
权限:需要管理员权限
|
||||||
|
"""
|
||||||
|
user_service = UserService(db)
|
||||||
|
user = await user_service.create_user(
|
||||||
|
obj_in=user_in,
|
||||||
|
created_by=current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"管理员创建用户",
|
||||||
|
admin_id=current_user.id,
|
||||||
|
admin_username=current_user.username,
|
||||||
|
new_user_id=user.id,
|
||||||
|
new_username=user.username,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录用户创建日志
|
||||||
|
await system_log_service.create_log(
|
||||||
|
db,
|
||||||
|
SystemLogCreate(
|
||||||
|
level="INFO",
|
||||||
|
type="user",
|
||||||
|
message=f"管理员 {current_user.username} 创建用户: {user.username}",
|
||||||
|
user_id=current_user.id,
|
||||||
|
user=current_user.username,
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
path="/api/v1/users/",
|
||||||
|
method="POST",
|
||||||
|
user_agent=request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(message="用户创建成功", data=UserSchema.model_validate(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=ResponseModel)
|
||||||
|
async def get_user(
|
||||||
|
user_id: int,
|
||||||
|
current_user: dict = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取用户详情
|
||||||
|
|
||||||
|
权限:需要登录
|
||||||
|
- 普通用户只能查看自己的信息
|
||||||
|
- 管理员和经理可以查看所有用户信息
|
||||||
|
"""
|
||||||
|
# 权限检查
|
||||||
|
if current_user.role == "trainee" and current_user.id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="没有权限查看其他用户信息"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取用户
|
||||||
|
user_service = UserService(db)
|
||||||
|
user = await user_service.get_by_id(user_id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
|
||||||
|
return ResponseModel(data=UserSchema.model_validate(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}", response_model=ResponseModel)
|
||||||
|
async def update_user(
|
||||||
|
user_id: int,
|
||||||
|
user_in: UserUpdate,
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
更新用户信息
|
||||||
|
|
||||||
|
权限:需要管理员权限
|
||||||
|
"""
|
||||||
|
user_service = UserService(db)
|
||||||
|
user = await user_service.update_user(
|
||||||
|
user_id=user_id,
|
||||||
|
obj_in=user_in,
|
||||||
|
updated_by=current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"管理员更新用户",
|
||||||
|
admin_id=current_user.id,
|
||||||
|
admin_username=current_user.username,
|
||||||
|
updated_user_id=user.id,
|
||||||
|
updated_username=user.username,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(data=UserSchema.model_validate(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}", response_model=ResponseModel)
|
||||||
|
async def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
删除用户(软删除)
|
||||||
|
|
||||||
|
权限:需要管理员权限
|
||||||
|
"""
|
||||||
|
# 不能删除自己
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能删除自己")
|
||||||
|
|
||||||
|
# 获取用户
|
||||||
|
user_service = UserService(db)
|
||||||
|
user = await user_service.get_by_id(user_id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
|
||||||
|
# 软删除
|
||||||
|
await user_service.soft_delete(db_obj=user)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"管理员删除用户",
|
||||||
|
admin_id=current_user.id,
|
||||||
|
admin_username=current_user.username,
|
||||||
|
deleted_user_id=user.id,
|
||||||
|
deleted_username=user.username,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录用户删除日志
|
||||||
|
await system_log_service.create_log(
|
||||||
|
db,
|
||||||
|
SystemLogCreate(
|
||||||
|
level="INFO",
|
||||||
|
type="user",
|
||||||
|
message=f"管理员 {current_user.username} 删除用户: {user.username}",
|
||||||
|
user_id=current_user.id,
|
||||||
|
user=current_user.username,
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
path=f"/api/v1/users/{user_id}",
|
||||||
|
method="DELETE",
|
||||||
|
user_agent=request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(message="用户删除成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/teams/{team_id}", response_model=ResponseModel)
|
||||||
|
async def add_user_to_team(
|
||||||
|
user_id: int,
|
||||||
|
team_id: int,
|
||||||
|
role: str = Query("member", regex="^(member|leader)$"),
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
将用户添加到团队
|
||||||
|
|
||||||
|
权限:需要管理员权限
|
||||||
|
"""
|
||||||
|
user_service = UserService(db)
|
||||||
|
await user_service.add_user_to_team(
|
||||||
|
user_id=user_id,
|
||||||
|
team_id=team_id,
|
||||||
|
role=role,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(message="用户已添加到团队")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}/teams/{team_id}", response_model=ResponseModel)
|
||||||
|
async def remove_user_from_team(
|
||||||
|
user_id: int,
|
||||||
|
team_id: int,
|
||||||
|
current_user: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
从团队中移除用户
|
||||||
|
|
||||||
|
权限:需要管理员权限
|
||||||
|
"""
|
||||||
|
user_service = UserService(db)
|
||||||
|
await user_service.remove_user_from_team(
|
||||||
|
user_id=user_id,
|
||||||
|
team_id=team_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(message="用户已从团队中移除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}/positions", response_model=ResponseModel)
|
||||||
|
async def get_user_positions(
|
||||||
|
user_id: int,
|
||||||
|
current_user: dict = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取用户所属岗位列表(用于前端展示与编辑)
|
||||||
|
|
||||||
|
权限:登录即可;普通用户仅能查看自己的信息
|
||||||
|
返回:[{id,name,code}]
|
||||||
|
"""
|
||||||
|
# 权限检查
|
||||||
|
if current_user.role == "trainee" and current_user.id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="没有权限查看其他用户信息")
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Position)
|
||||||
|
.join(PositionMember, PositionMember.position_id == Position.id)
|
||||||
|
.where(PositionMember.user_id == user_id, PositionMember.is_deleted == False, Position.is_deleted == False)
|
||||||
|
.order_by(Position.id)
|
||||||
|
)
|
||||||
|
rows = (await db.execute(stmt)).scalars().all()
|
||||||
|
data = [
|
||||||
|
{"id": p.id, "name": p.name, "code": p.code}
|
||||||
|
for p in rows
|
||||||
|
]
|
||||||
|
return ResponseModel(data=data)
|
||||||
120
backend/app/api/v1/yanji.py
Normal file
120
backend/app/api/v1/yanji.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""
|
||||||
|
言迹智能工牌API接口
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.base import ResponseModel
|
||||||
|
from app.schemas.yanji import (
|
||||||
|
GetConversationsByVisitIdsResponse,
|
||||||
|
GetConversationsResponse,
|
||||||
|
YanjiConversation,
|
||||||
|
)
|
||||||
|
from app.services.yanji_service import YanjiService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/conversations/by-visit-ids", response_model=ResponseModel[GetConversationsByVisitIdsResponse])
|
||||||
|
async def get_conversations_by_visit_ids(
|
||||||
|
external_visit_ids: List[str] = Query(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
max_length=10,
|
||||||
|
description="三方来访单ID列表(最多10个)",
|
||||||
|
),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
根据来访单ID获取对话记录(ASR转写文字)
|
||||||
|
|
||||||
|
这是获取对话记录的主要接口,适用于:
|
||||||
|
1. 已知来访单ID的场景
|
||||||
|
2. 获取特定对话记录用于AI评分
|
||||||
|
3. 批量获取多个对话记录
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
yanji_service = YanjiService()
|
||||||
|
conversations = await yanji_service.get_conversations_by_visit_ids(
|
||||||
|
external_visit_ids=external_visit_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取成功",
|
||||||
|
data=GetConversationsByVisitIdsResponse(
|
||||||
|
conversations=conversations, total=len(conversations)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取对话记录失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"获取失败: {str(e)}", data=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/conversations", response_model=ResponseModel[GetConversationsResponse])
|
||||||
|
async def get_employee_conversations(
|
||||||
|
consultant_phone: str = Query(..., description="员工手机号"),
|
||||||
|
limit: int = Query(10, ge=1, le=100, description="获取数量"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取员工最近的对话记录
|
||||||
|
|
||||||
|
注意:目前此接口功能有限,因为言迹API没有直接通过员工手机号查询录音的接口。
|
||||||
|
推荐使用 /conversations/by-visit-ids 接口。
|
||||||
|
|
||||||
|
后续可扩展:
|
||||||
|
1. 先查询员工的来访单列表
|
||||||
|
2. 再获取这些来访单的对话记录
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
yanji_service = YanjiService()
|
||||||
|
conversations = await yanji_service.get_recent_conversations(
|
||||||
|
consultant_phone=consultant_phone, limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="获取成功",
|
||||||
|
data=GetConversationsResponse(
|
||||||
|
conversations=conversations, total=len(conversations)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取员工对话记录失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"获取失败: {str(e)}", data=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/test-auth")
|
||||||
|
async def test_yanji_auth(current_user: User = Depends(get_current_user)):
|
||||||
|
"""
|
||||||
|
测试言迹API认证
|
||||||
|
|
||||||
|
用于验证OAuth2.0认证是否正常工作
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
yanji_service = YanjiService()
|
||||||
|
access_token = await yanji_service.get_access_token()
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
code=200,
|
||||||
|
message="认证成功",
|
||||||
|
data={
|
||||||
|
"access_token": access_token[:20] + "...", # 只显示前20个字符
|
||||||
|
"base_url": yanji_service.base_url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"言迹API认证失败: {e}", exc_info=True)
|
||||||
|
return ResponseModel(code=500, message=f"认证失败: {str(e)}", data=None)
|
||||||
|
|
||||||
0
backend/app/config/__init__.py
Normal file
0
backend/app/config/__init__.py
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user