Compare commits
22 Commits
0b7c07eb7f
...
41a2f7944a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41a2f7944a | ||
|
|
18d6d5aff3 | ||
|
|
7be1ac1787 | ||
|
|
cabc3c3442 | ||
|
|
07638152fc | ||
|
|
78e1bb3dc3 | ||
|
|
8500308919 | ||
|
|
d2e6abfc80 | ||
|
|
9bd9e58439 | ||
|
|
0b8f8aa6ca | ||
|
|
c3aa4e85e7 | ||
|
|
4e817f6eef | ||
|
|
64a70d5c2c | ||
|
|
e1d10605c9 | ||
|
|
50c511d825 | ||
|
|
2334a2544c | ||
|
|
ae4ba8afd3 | ||
|
|
4a273e627a | ||
|
|
bdb91aabea | ||
|
|
79b55cfd12 | ||
|
|
52dccaab79 | ||
|
|
d59a4355a5 |
155
TEST_REPORT_2026-01-31.md
Normal file
155
TEST_REPORT_2026-01-31.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# KPL 考培练系统测试报告
|
||||||
|
|
||||||
|
**测试环境**: dev (https://kpl.ireborn.com.cn)
|
||||||
|
**测试时间**: 2026-01-31
|
||||||
|
**测试人员**: AI 自动化测试系统
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、测试概要
|
||||||
|
|
||||||
|
| 模块 | 测试用例数 | 通过 | 失败 | 警告 |
|
||||||
|
|------|-----------|------|------|------|
|
||||||
|
| 认证模块 | 7 | 5 | 2 | 0 |
|
||||||
|
| 课程管理 | 7 | 7 | 0 | 0 |
|
||||||
|
| 成长路径 | 4 | 4 | 0 | 0 |
|
||||||
|
| 岗位管理 | 2 | 2 | 0 | 0 |
|
||||||
|
| 考试模块 | 3 | 2 | 1 | 0 |
|
||||||
|
| AI练习 | 3 | 2 | 0 | 1 |
|
||||||
|
| 通知系统 | 2 | 2 | 0 | 0 |
|
||||||
|
| 极端边界 | 8 | 7 | 0 | 1 |
|
||||||
|
| 安全测试 | 7 | 5 | 0 | 2 |
|
||||||
|
| **合计** | **43** | **36** | **3** | **4** |
|
||||||
|
|
||||||
|
**通过率**: 83.7%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、发现的问题
|
||||||
|
|
||||||
|
### 严重 (High)
|
||||||
|
|
||||||
|
#### 1. 错误密码登录返回200
|
||||||
|
- **位置**: `POST /api/v1/auth/login`
|
||||||
|
- **描述**: 使用错误密码登录时返回 HTTP 200,应返回 401
|
||||||
|
- **影响**: 可能导致暴力破解攻击难以被检测
|
||||||
|
- **建议**: 检查登录逻辑,确保密码错误时返回 401
|
||||||
|
|
||||||
|
#### 2. XSS 内容被原样存储
|
||||||
|
- **位置**: `POST /api/v1/courses` (name, description 字段)
|
||||||
|
- **描述**: `<script>alert(1)</script>` 等 XSS 代码被原样存入数据库
|
||||||
|
- **影响**: 潜在的存储型 XSS 攻击风险
|
||||||
|
- **建议**:
|
||||||
|
- 输入时转义或过滤 HTML 标签
|
||||||
|
- 输出时使用 HTML 实体编码
|
||||||
|
|
||||||
|
### 中等 (Medium)
|
||||||
|
|
||||||
|
#### 3. 不存在用户登录返回422
|
||||||
|
- **位置**: `POST /api/v1/auth/login`
|
||||||
|
- **描述**: 登录不存在的用户返回 422,应返回 401
|
||||||
|
- **影响**: 用户枚举风险(可判断用户是否存在)
|
||||||
|
- **建议**: 统一返回 401 "用户名或密码错误"
|
||||||
|
|
||||||
|
#### 4. API 限流未配置
|
||||||
|
- **位置**: 全局
|
||||||
|
- **描述**: 10次快速请求未触发限流
|
||||||
|
- **影响**: 可能被恶意请求攻击
|
||||||
|
- **建议**: 配置 API 限流中间件
|
||||||
|
|
||||||
|
### 低等 (Low)
|
||||||
|
|
||||||
|
#### 5. 越权访问返回404而非403
|
||||||
|
- **位置**: `GET /api/v1/admin/users`
|
||||||
|
- **描述**: 普通用户访问管理接口返回 404 而非 403
|
||||||
|
- **影响**: 信息泄露(可探测接口是否存在)
|
||||||
|
- **建议**: 统一返回 403 Forbidden
|
||||||
|
|
||||||
|
#### 6. 部分API端点404
|
||||||
|
- **位置**:
|
||||||
|
- `GET /api/v1/exams` (考试列表)
|
||||||
|
- `GET /api/v1/practice/sessions` (练习记录)
|
||||||
|
- **描述**: 这些端点返回 404,可能是路径变更或未实现
|
||||||
|
- **建议**: 确认 API 路径或补充实现
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、测试详情
|
||||||
|
|
||||||
|
### 3.1 认证模块测试
|
||||||
|
|
||||||
|
| 测试项 | 结果 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 正常登录 | ✓ PASS | HTTP 200, Token 获取成功 |
|
||||||
|
| 错误密码登录 | ✗ FAIL | HTTP 200 (应返回401) |
|
||||||
|
| 不存在用户登录 | ✗ FAIL | HTTP 422 (应返回401) |
|
||||||
|
| Token验证 | ✓ PASS | HTTP 200 |
|
||||||
|
| 无效Token访问 | ✓ PASS | HTTP 401 |
|
||||||
|
| 无Token访问 | ✓ PASS | HTTP 403 |
|
||||||
|
| 获取用户信息 | ✓ PASS | HTTP 200 |
|
||||||
|
|
||||||
|
### 3.2 课程管理测试
|
||||||
|
|
||||||
|
| 测试项 | 结果 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 获取课程列表 | ✓ PASS | 总课程数: 16 |
|
||||||
|
| 创建课程 | ✓ PASS | HTTP 201 |
|
||||||
|
| 获取课程详情 | ✓ PASS | HTTP 200 |
|
||||||
|
| 更新课程 | ✓ PASS | HTTP 200 |
|
||||||
|
| 获取考试设置 | ✓ PASS | HTTP 200 |
|
||||||
|
| 更新考试设置 | ✓ PASS | HTTP 200 |
|
||||||
|
| 获取不存在课程 | ✓ PASS | HTTP 404 |
|
||||||
|
|
||||||
|
### 3.3 极端边界测试
|
||||||
|
|
||||||
|
| 测试项 | 结果 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 空名称创建课程 | ✓ PASS | 正确返回 422 |
|
||||||
|
| 超长名称(1000字符) | ✓ PASS | 正确返回 422 |
|
||||||
|
| XSS注入 | ⚠ WARN | 内容被原样存储 |
|
||||||
|
| SQL注入 | ✓ PASS | 注入被防护 |
|
||||||
|
| 负数分页参数 | ✓ PASS | 正确返回 422 |
|
||||||
|
| 超大分页(10000) | ✓ PASS | 正确返回 422 |
|
||||||
|
| Unicode/Emoji | ✓ PASS | 正确处理 |
|
||||||
|
| 特殊字符 | ✓ PASS | 正确处理 |
|
||||||
|
|
||||||
|
### 3.4 安全测试
|
||||||
|
|
||||||
|
| 测试项 | 结果 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 越权访问 | ⚠ WARN | 返回404而非403 |
|
||||||
|
| 伪造Token | ✓ PASS | 正确拒绝 |
|
||||||
|
| 过期Token | ✓ PASS | 正确拒绝 |
|
||||||
|
| 访问他人数据 | ✓ PASS | 访问被限制 |
|
||||||
|
| 敏感信息泄露 | ✓ PASS | 未泄露密码/Token |
|
||||||
|
| API限流 | ⚠ INFO | 未触发限流 |
|
||||||
|
| 目录遍历 | ✓ PASS | 攻击被阻止 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、修复建议优先级
|
||||||
|
|
||||||
|
### P0 - 立即修复
|
||||||
|
1. 修复错误密码登录返回200的问题
|
||||||
|
2. 添加 XSS 输入过滤/输出编码
|
||||||
|
|
||||||
|
### P1 - 尽快修复
|
||||||
|
3. 统一登录错误响应码为401
|
||||||
|
4. 配置 API 限流保护
|
||||||
|
|
||||||
|
### P2 - 计划修复
|
||||||
|
5. 越权访问统一返回403
|
||||||
|
6. 确认并修复404的API端点
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、测试环境信息
|
||||||
|
|
||||||
|
- **后端容器**: kpl-backend-dev
|
||||||
|
- **数据库**: MySQL 8.0
|
||||||
|
- **测试账号**: admin / admin123
|
||||||
|
- **测试时间**: 2026-01-31 10:30 UTC+8
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本报告由自动化测试系统生成*
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
# 此文件备份了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})
|
|
||||||
@@ -67,7 +67,7 @@ async def login(
|
|||||||
SystemLogCreate(
|
SystemLogCreate(
|
||||||
level="WARNING",
|
level="WARNING",
|
||||||
type="security",
|
type="security",
|
||||||
message=f"用户 {login_data.username} 登录失败:密码错误",
|
message=f"用户 {login_data.username} 登录失败:用户名或密码错误",
|
||||||
user=login_data.username,
|
user=login_data.username,
|
||||||
ip=request.client.host if request.client else None,
|
ip=request.client.host if request.client else None,
|
||||||
path="/api/v1/auth/login",
|
path="/api/v1/auth/login",
|
||||||
@@ -75,19 +75,27 @@ async def login(
|
|||||||
user_agent=request.headers.get("user-agent")
|
user_agent=request.headers.get("user-agent")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# 不返回 401,统一返回 HTTP 200 + 业务失败码,便于前端友好提示
|
|
||||||
logger.warning("login_failed_wrong_credentials", username=login_data.username)
|
logger.warning("login_failed_wrong_credentials", username=login_data.username)
|
||||||
return ResponseModel(
|
# 返回 HTTP 401 + 统一错误消息(避免用户枚举)
|
||||||
code=400,
|
from fastapi.responses import JSONResponse
|
||||||
message=str(e) or "用户名或密码错误",
|
return JSONResponse(
|
||||||
data=None,
|
status_code=401,
|
||||||
|
content={
|
||||||
|
"code": 401,
|
||||||
|
"message": "用户名或密码错误",
|
||||||
|
"data": None,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("login_failed_unexpected", error=str(e))
|
logger.error("login_failed_unexpected", error=str(e))
|
||||||
return ResponseModel(
|
from fastapi.responses import JSONResponse
|
||||||
code=500,
|
return JSONResponse(
|
||||||
message="登录失败,请稍后重试",
|
status_code=500,
|
||||||
data=None,
|
content={
|
||||||
|
"code": 500,
|
||||||
|
"message": "登录失败,请稍后重试",
|
||||||
|
"data": None,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status, BackgroundTasks, Request
|
from fastapi import APIRouter, Depends, Query, status, BackgroundTasks, Request
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.deps import get_db, get_current_user, require_admin, require_admin_or_manager, User
|
||||||
|
|||||||
@@ -203,25 +203,29 @@ async def send_message(request: SendMessageRequest, user=Depends(get_current_use
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
except CozeException as e:
|
except CozeException as coze_err:
|
||||||
logger.error(f"发送消息失败: {e}")
|
logger.error(f"发送消息失败: {coze_err}")
|
||||||
if request.stream:
|
if request.stream:
|
||||||
# 流式响应的错误处理
|
# 流式响应的错误处理 - 捕获异常信息避免闭包问题
|
||||||
|
err_code = coze_err.code
|
||||||
|
err_message = coze_err.message
|
||||||
|
err_details = coze_err.details
|
||||||
|
|
||||||
async def error_generator():
|
async def error_generator():
|
||||||
yield {
|
yield {
|
||||||
"event": "error",
|
"event": "error",
|
||||||
"data": {
|
"data": {
|
||||||
"code": e.code,
|
"code": err_code,
|
||||||
"message": e.message,
|
"message": err_message,
|
||||||
"details": e.details,
|
"details": err_details,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return EventSourceResponse(error_generator())
|
return EventSourceResponse(error_generator())
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=e.status_code or 500,
|
status_code=coze_err.status_code or 500,
|
||||||
detail={"code": e.code, "message": e.message, "details": e.details},
|
detail={"code": err_code, "message": err_message, "details": err_details},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"未知错误: {e}", exc_info=True)
|
logger.error(f"未知错误: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_db, get_current_user
|
from app.core.deps import get_db, get_current_user, require_admin_or_manager
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.growth_path_service import growth_path_service
|
from app.services.growth_path_service import growth_path_service
|
||||||
from app.schemas.growth_path import (
|
from app.schemas.growth_path import (
|
||||||
@@ -118,7 +118,7 @@ async def list_growth_paths(
|
|||||||
page: int = Query(1, ge=1, description="页码"),
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_admin_or_manager),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取成长路径列表(管理端)
|
获取成长路径列表(管理端)
|
||||||
@@ -141,7 +141,7 @@ async def list_growth_paths(
|
|||||||
async def create_growth_path(
|
async def create_growth_path(
|
||||||
data: GrowthPathCreate,
|
data: GrowthPathCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_admin_or_manager),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
创建成长路径(管理端)
|
创建成长路径(管理端)
|
||||||
@@ -168,7 +168,7 @@ async def create_growth_path(
|
|||||||
async def get_growth_path(
|
async def get_growth_path(
|
||||||
path_id: int,
|
path_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_admin_or_manager),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取成长路径详情(管理端)
|
获取成长路径详情(管理端)
|
||||||
@@ -190,7 +190,7 @@ async def update_growth_path(
|
|||||||
path_id: int,
|
path_id: int,
|
||||||
data: GrowthPathUpdate,
|
data: GrowthPathUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_admin_or_manager),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
更新成长路径(管理端)
|
更新成长路径(管理端)
|
||||||
@@ -216,7 +216,7 @@ async def update_growth_path(
|
|||||||
async def delete_growth_path(
|
async def delete_growth_path(
|
||||||
path_id: int,
|
path_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_admin_or_manager),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
删除成长路径(管理端)
|
删除成长路径(管理端)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.models.exam_mistake import ExamMistake
|
|||||||
from app.models.position_member import PositionMember
|
from app.models.position_member import PositionMember
|
||||||
from app.models.position_course import PositionCourse
|
from app.models.position_course import PositionCourse
|
||||||
from app.schemas.base import ResponseModel
|
from app.schemas.base import ResponseModel
|
||||||
|
from app.services.exam_service import ExamService
|
||||||
from app.schemas.exam import (
|
from app.schemas.exam import (
|
||||||
StartExamRequest,
|
StartExamRequest,
|
||||||
StartExamResponse,
|
StartExamResponse,
|
||||||
@@ -61,9 +62,13 @@ async def start_exam(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""开始考试"""
|
"""开始考试"""
|
||||||
exam = await ExamService.start_exam(
|
# 先提取用户信息,避免后续懒加载问题
|
||||||
|
user_id = current_user.id
|
||||||
|
username = current_user.username
|
||||||
|
|
||||||
|
exam_id = await ExamService.start_exam(
|
||||||
db=db,
|
db=db,
|
||||||
user_id=current_user.id,
|
user_id=user_id,
|
||||||
course_id=request.course_id,
|
course_id=request.course_id,
|
||||||
question_count=request.count,
|
question_count=request.count,
|
||||||
)
|
)
|
||||||
@@ -81,9 +86,9 @@ async def start_exam(
|
|||||||
SystemLogCreate(
|
SystemLogCreate(
|
||||||
level="INFO",
|
level="INFO",
|
||||||
type="api",
|
type="api",
|
||||||
message=f"用户 {current_user.username} 开始考试(课程ID: {request.course_id})",
|
message=f"用户 {username} 开始考试(课程ID: {request.course_id})",
|
||||||
user_id=current_user.id,
|
user_id=user_id,
|
||||||
user=current_user.username,
|
user=username,
|
||||||
ip=http_request.client.host if http_request.client else None,
|
ip=http_request.client.host if http_request.client else None,
|
||||||
path="/api/v1/exams/start",
|
path="/api/v1/exams/start",
|
||||||
method="POST",
|
method="POST",
|
||||||
@@ -91,7 +96,7 @@ async def start_exam(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return ResponseModel(code=200, data=StartExamResponse(exam_id=exam.id), message="考试开始")
|
return ResponseModel(code=200, data=StartExamResponse(exam_id=exam_id), message="考试开始")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/submit", response_model=ResponseModel[SubmitExamResponse])
|
@router.post("/submit", response_model=ResponseModel[SubmitExamResponse])
|
||||||
@@ -102,8 +107,12 @@ async def submit_exam(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""提交考试答案"""
|
"""提交考试答案"""
|
||||||
|
# 先提取用户信息,避免后续懒加载问题
|
||||||
|
user_id = current_user.id
|
||||||
|
username = current_user.username
|
||||||
|
|
||||||
result = await ExamService.submit_exam(
|
result = await ExamService.submit_exam(
|
||||||
db=db, user_id=current_user.id, exam_id=request.exam_id, answers=request.answers
|
db=db, user_id=user_id, exam_id=request.exam_id, answers=request.answers
|
||||||
)
|
)
|
||||||
|
|
||||||
# 获取考试记录以获取course_id
|
# 获取考试记录以获取course_id
|
||||||
@@ -125,9 +134,9 @@ async def submit_exam(
|
|||||||
SystemLogCreate(
|
SystemLogCreate(
|
||||||
level="INFO",
|
level="INFO",
|
||||||
type="api",
|
type="api",
|
||||||
message=f"用户 {current_user.username} 提交考试(考试ID: {request.exam_id},得分: {result.get('score', 0)})",
|
message=f"用户 {username} 提交考试(考试ID: {request.exam_id},得分: {result.get('score', 0)})",
|
||||||
user_id=current_user.id,
|
user_id=user_id,
|
||||||
user=current_user.username,
|
user=username,
|
||||||
ip=http_request.client.host if http_request.client else None,
|
ip=http_request.client.host if http_request.client else None,
|
||||||
path="/api/v1/exams/submit",
|
path="/api/v1/exams/submit",
|
||||||
method="POST",
|
method="POST",
|
||||||
@@ -251,20 +260,6 @@ async def get_mistakes(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@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])
|
@router.get("/records", response_model=ResponseModel[dict])
|
||||||
async def get_exam_records(
|
async def get_exam_records(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
@@ -295,6 +290,21 @@ async def get_exam_statistics(
|
|||||||
return ResponseModel(code=200, data=stats, message="获取成功")
|
return ResponseModel(code=200, data=stats, message="获取成功")
|
||||||
|
|
||||||
|
|
||||||
|
# 注意:动态路由 /{exam_id} 必须放在固定路由之后
|
||||||
|
@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.post("/generate", response_model=ResponseModel[GenerateExamResponse])
|
@router.post("/generate", response_model=ResponseModel[GenerateExamResponse])
|
||||||
|
|||||||
@@ -724,8 +724,15 @@ async def end_practice_session(
|
|||||||
new_badges = await badge_service.check_and_award_badges(current_user.id)
|
new_badges = await badge_service.check_and_award_badges(current_user.id)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
# 第二次commit后需要refresh,避免DetachedInstanceError
|
||||||
|
await db.refresh(session)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"练习经验值/奖章处理失败: {str(e)}")
|
logger.warning(f"练习经验值/奖章处理失败: {str(e)}")
|
||||||
|
# 确保 session 仍然可用
|
||||||
|
try:
|
||||||
|
await db.refresh(session)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return ResponseModel(
|
return ResponseModel(
|
||||||
code=200,
|
code=200,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.engine.result import Result
|
from sqlalchemy.engine.result import Result
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db
|
from app.core.deps import get_current_user, get_db, require_admin
|
||||||
try:
|
try:
|
||||||
from app.core.simple_auth import get_current_user_simple
|
from app.core.simple_auth import get_current_user_simple
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -57,7 +57,7 @@ def serialize_row(row: Any) -> Union[Dict[str, Any], Any]:
|
|||||||
@router.post("/execute", response_model=ResponseModel)
|
@router.post("/execute", response_model=ResponseModel)
|
||||||
async def execute_sql(
|
async def execute_sql(
|
||||||
request: Dict[str, Any],
|
request: Dict[str, Any],
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_admin),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
) -> ResponseModel:
|
) -> ResponseModel:
|
||||||
"""
|
"""
|
||||||
@@ -74,7 +74,7 @@ async def execute_sql(
|
|||||||
- 写入操作:返回影响的行数
|
- 写入操作:返回影响的行数
|
||||||
|
|
||||||
安全说明:
|
安全说明:
|
||||||
- 需要用户身份验证
|
- 需要管理员权限
|
||||||
- 所有操作都会记录日志
|
- 所有操作都会记录日志
|
||||||
- 建议在生产环境中限制可执行的 SQL 类型
|
- 建议在生产环境中限制可执行的 SQL 类型
|
||||||
"""
|
"""
|
||||||
@@ -196,11 +196,13 @@ async def execute_sql(
|
|||||||
@router.post("/validate", response_model=ResponseModel)
|
@router.post("/validate", response_model=ResponseModel)
|
||||||
async def validate_sql(
|
async def validate_sql(
|
||||||
request: Dict[str, Any],
|
request: Dict[str, Any],
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(require_admin)
|
||||||
) -> ResponseModel:
|
) -> ResponseModel:
|
||||||
"""
|
"""
|
||||||
验证 SQL 语句的语法(不执行)
|
验证 SQL 语句的语法(不执行)
|
||||||
|
|
||||||
|
权限:需要管理员权限
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: 包含 sql 字段的请求
|
request: 包含 sql 字段的请求
|
||||||
|
|
||||||
@@ -253,12 +255,14 @@ async def validate_sql(
|
|||||||
|
|
||||||
@router.get("/tables", response_model=ResponseModel)
|
@router.get("/tables", response_model=ResponseModel)
|
||||||
async def get_tables(
|
async def get_tables(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_admin),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
) -> ResponseModel:
|
) -> ResponseModel:
|
||||||
"""
|
"""
|
||||||
获取数据库中的所有表
|
获取数据库中的所有表
|
||||||
|
|
||||||
|
权限:需要管理员权限
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
数据库表列表
|
数据库表列表
|
||||||
"""
|
"""
|
||||||
@@ -290,12 +294,14 @@ async def get_tables(
|
|||||||
@router.get("/table/{table_name}/schema", response_model=ResponseModel)
|
@router.get("/table/{table_name}/schema", response_model=ResponseModel)
|
||||||
async def get_table_schema(
|
async def get_table_schema(
|
||||||
table_name: str,
|
table_name: str,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_admin),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
) -> ResponseModel:
|
) -> ResponseModel:
|
||||||
"""
|
"""
|
||||||
获取指定表的结构信息
|
获取指定表的结构信息
|
||||||
|
|
||||||
|
权限:需要管理员权限
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
table_name: 表名
|
table_name: 表名
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ class DingtalkConfigResponse(BaseModel):
|
|||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeSyncConfigUpdate(BaseModel):
|
||||||
|
"""员工同步配置更新请求(复用钉钉免密登录配置)"""
|
||||||
|
enabled: Optional[bool] = Field(None, description="是否启用自动同步")
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 辅助函数
|
# 辅助函数
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -277,6 +282,133 @@ async def update_dingtalk_config(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/employee-sync", response_model=ResponseModel)
|
||||||
|
async def get_employee_sync_config(
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
获取员工同步配置(复用钉钉免密登录配置)
|
||||||
|
|
||||||
|
仅限管理员访问
|
||||||
|
"""
|
||||||
|
check_admin_permission(current_user)
|
||||||
|
|
||||||
|
tenant_id = await get_or_create_tenant_id(db)
|
||||||
|
|
||||||
|
# 从 dingtalk 配置组读取(与免密登录共用)
|
||||||
|
corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID')
|
||||||
|
app_key = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY')
|
||||||
|
app_secret = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET')
|
||||||
|
enabled = await get_feature_switch(db, tenant_id, 'employee_sync')
|
||||||
|
dingtalk_enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login')
|
||||||
|
|
||||||
|
# 检查钉钉配置是否完整
|
||||||
|
configured = bool(corp_id and app_key and app_secret)
|
||||||
|
|
||||||
|
return ResponseModel(
|
||||||
|
message="获取成功",
|
||||||
|
data={
|
||||||
|
"enabled": enabled,
|
||||||
|
"configured": configured,
|
||||||
|
"dingtalk_enabled": dingtalk_enabled, # 免密登录是否启用
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/employee-sync", response_model=ResponseModel)
|
||||||
|
async def update_employee_sync_config(
|
||||||
|
config: EmployeeSyncConfigUpdate,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
更新员工同步配置(仅开关,API 凭证复用钉钉免密登录)
|
||||||
|
|
||||||
|
仅限管理员访问
|
||||||
|
"""
|
||||||
|
check_admin_permission(current_user)
|
||||||
|
|
||||||
|
tenant_id = await get_or_create_tenant_id(db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if config.enabled is not None:
|
||||||
|
await set_feature_switch(db, tenant_id, 'employee_sync', config.enabled)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"员工同步配置已更新",
|
||||||
|
user_id=current_user.id,
|
||||||
|
username=current_user.username,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseModel(message="配置已保存")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"更新员工同步配置失败: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="保存配置失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/employee-sync/test", response_model=ResponseModel)
|
||||||
|
async def test_employee_sync_connection(
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ResponseModel:
|
||||||
|
"""
|
||||||
|
测试钉钉 API 连接(复用免密登录配置)
|
||||||
|
|
||||||
|
仅限管理员访问
|
||||||
|
"""
|
||||||
|
check_admin_permission(current_user)
|
||||||
|
|
||||||
|
tenant_id = await get_or_create_tenant_id(db)
|
||||||
|
|
||||||
|
# 从 dingtalk 配置组读取(与免密登录共用)
|
||||||
|
corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID')
|
||||||
|
client_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY')
|
||||||
|
client_secret = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET')
|
||||||
|
|
||||||
|
if not all([corp_id, client_id, client_secret]):
|
||||||
|
return ResponseModel(
|
||||||
|
code=400,
|
||||||
|
message="请先在「钉钉免密登录」页签配置 CorpId、AppKey、AppSecret"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.dingtalk_service import DingTalkService
|
||||||
|
|
||||||
|
dingtalk = DingTalkService(
|
||||||
|
corp_id=corp_id,
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await dingtalk.test_connection()
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
return ResponseModel(
|
||||||
|
message=f"连接成功!已获取到组织架构",
|
||||||
|
data=result
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return ResponseModel(
|
||||||
|
code=500,
|
||||||
|
message=result["message"]
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"测试连接失败: {str(e)}")
|
||||||
|
return ResponseModel(
|
||||||
|
code=500,
|
||||||
|
message=f"连接失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/all", response_model=ResponseModel)
|
@router.get("/all", response_model=ResponseModel)
|
||||||
async def get_all_settings(
|
async def get_all_settings(
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
@@ -295,12 +427,20 @@ async def get_all_settings(
|
|||||||
dingtalk_enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login')
|
dingtalk_enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login')
|
||||||
dingtalk_corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID')
|
dingtalk_corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID')
|
||||||
|
|
||||||
|
# 员工同步配置状态
|
||||||
|
employee_sync_enabled = await get_feature_switch(db, tenant_id, 'employee_sync')
|
||||||
|
employee_sync_host = await get_system_config(db, tenant_id, 'employee_sync', 'DB_HOST')
|
||||||
|
|
||||||
return ResponseModel(
|
return ResponseModel(
|
||||||
message="获取成功",
|
message="获取成功",
|
||||||
data={
|
data={
|
||||||
"dingtalk": {
|
"dingtalk": {
|
||||||
"enabled": dingtalk_enabled,
|
"enabled": dingtalk_enabled,
|
||||||
"configured": bool(dingtalk_corp_id), # 是否已配置
|
"configured": bool(dingtalk_corp_id),
|
||||||
|
},
|
||||||
|
"employee_sync": {
|
||||||
|
"enabled": employee_sync_enabled,
|
||||||
|
"configured": bool(employee_sync_host),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -226,3 +226,44 @@ async def delete_task(
|
|||||||
|
|
||||||
return ResponseModel(message="任务已删除")
|
return ResponseModel(message="任务已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{task_id}/remind", response_model=ResponseModel, summary="发送任务提醒")
|
||||||
|
async def send_task_reminder(
|
||||||
|
task_id: int,
|
||||||
|
request: Request,
|
||||||
|
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="任务不存在")
|
||||||
|
|
||||||
|
# 获取未完成的成员数量
|
||||||
|
incomplete_count = sum(1 for a in task.assignments if a.status.value != "completed")
|
||||||
|
|
||||||
|
if incomplete_count == 0:
|
||||||
|
return ResponseModel(message="所有成员已完成任务,无需发送提醒")
|
||||||
|
|
||||||
|
# 记录提醒日志
|
||||||
|
await system_log_service.create_log(
|
||||||
|
db,
|
||||||
|
SystemLogCreate(
|
||||||
|
level="INFO",
|
||||||
|
type="notification",
|
||||||
|
message=f"发送任务提醒: {task.title},提醒 {incomplete_count} 人",
|
||||||
|
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}/remind",
|
||||||
|
method="POST",
|
||||||
|
user_agent=request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: 实际发送通知逻辑(通过通知服务)
|
||||||
|
# 可以调用 notification_service.send_task_reminder(task, incomplete_assignments)
|
||||||
|
|
||||||
|
return ResponseModel(message=f"已向 {incomplete_count} 位未完成成员发送提醒")
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ logger = get_logger(__name__)
|
|||||||
router = APIRouter(prefix="/upload")
|
router = APIRouter(prefix="/upload")
|
||||||
|
|
||||||
# 支持的文件类型和大小限制
|
# 支持的文件类型和大小限制
|
||||||
# 支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties
|
# 支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、PPT、CSV、VTT、Properties
|
||||||
ALLOWED_EXTENSIONS = {
|
ALLOWED_EXTENSIONS = {
|
||||||
'txt', 'md', 'mdx', 'pdf', 'html', 'htm',
|
'txt', 'md', 'mdx', 'pdf', 'html', 'htm',
|
||||||
'xlsx', 'xls', 'docx', 'doc', 'csv', 'vtt', 'properties'
|
'xlsx', 'xls', 'docx', 'doc', 'pptx', 'ppt', 'csv', 'vtt', 'properties'
|
||||||
}
|
}
|
||||||
MAX_FILE_SIZE = 15 * 1024 * 1024 # 15MB
|
MAX_FILE_SIZE = 15 * 1024 * 1024 # 15MB
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from app.core.logger import logger
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.base import PaginatedResponse, PaginationParams, ResponseModel
|
from app.schemas.base import PaginatedResponse, PaginationParams, ResponseModel
|
||||||
from app.schemas.user import User as UserSchema
|
from app.schemas.user import User as UserSchema
|
||||||
from app.schemas.user import UserCreate, UserFilter, UserPasswordUpdate, UserUpdate
|
from app.schemas.user import UserCreate, UserFilter, UserPasswordUpdate, UserUpdate, UserSelfUpdate
|
||||||
from app.services.user_service import UserService
|
from app.services.user_service import UserService
|
||||||
from app.services.system_log_service import system_log_service
|
from app.services.system_log_service import system_log_service
|
||||||
from app.schemas.system_log import SystemLogCreate
|
from app.schemas.system_log import SystemLogCreate
|
||||||
@@ -157,7 +157,7 @@ async def get_recent_exams(
|
|||||||
|
|
||||||
@router.put("/me", response_model=ResponseModel)
|
@router.put("/me", response_model=ResponseModel)
|
||||||
async def update_current_user(
|
async def update_current_user(
|
||||||
user_in: UserUpdate,
|
user_in: UserSelfUpdate,
|
||||||
current_user: dict = Depends(get_current_active_user),
|
current_user: dict = Depends(get_current_active_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> ResponseModel:
|
) -> ResponseModel:
|
||||||
@@ -165,6 +165,7 @@ async def update_current_user(
|
|||||||
更新当前用户信息
|
更新当前用户信息
|
||||||
|
|
||||||
权限:需要登录
|
权限:需要登录
|
||||||
|
注意:用户只能修改自己的基本信息,不能修改角色(role)和激活状态(is_active)
|
||||||
"""
|
"""
|
||||||
user_service = UserService(db)
|
user_service = UserService(db)
|
||||||
user = await user_service.update_user(
|
user = await user_service.update_user(
|
||||||
|
|||||||
@@ -3,14 +3,129 @@
|
|||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Callable
|
from typing import Callable, Dict
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from app.core.logger import logger
|
from app.core.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""
|
||||||
|
API 限流中间件
|
||||||
|
|
||||||
|
基于IP地址进行限流,防止恶意请求攻击
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app, requests_per_minute: int = 60, burst_limit: int = 100):
|
||||||
|
super().__init__(app)
|
||||||
|
self.requests_per_minute = requests_per_minute
|
||||||
|
self.burst_limit = burst_limit # 突发请求限制
|
||||||
|
self.request_counts: Dict[str, list] = defaultdict(list)
|
||||||
|
|
||||||
|
def _get_client_ip(self, request: Request) -> str:
|
||||||
|
"""获取客户端真实IP"""
|
||||||
|
# 优先从代理头获取
|
||||||
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
real_ip = request.headers.get("X-Real-IP")
|
||||||
|
if real_ip:
|
||||||
|
return real_ip
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
def _clean_old_requests(self, ip: str, window_start: datetime):
|
||||||
|
"""清理窗口外的请求记录"""
|
||||||
|
self.request_counts[ip] = [
|
||||||
|
t for t in self.request_counts[ip]
|
||||||
|
if t > window_start
|
||||||
|
]
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||||
|
# 跳过健康检查和静态文件
|
||||||
|
if request.url.path in ["/health", "/docs", "/openapi.json", "/redoc"]:
|
||||||
|
return await call_next(request)
|
||||||
|
if request.url.path.startswith("/static/"):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
client_ip = self._get_client_ip(request)
|
||||||
|
now = datetime.now()
|
||||||
|
window_start = now - timedelta(minutes=1)
|
||||||
|
|
||||||
|
# 清理过期记录
|
||||||
|
self._clean_old_requests(client_ip, window_start)
|
||||||
|
|
||||||
|
# 检查请求数
|
||||||
|
request_count = len(self.request_counts[client_ip])
|
||||||
|
|
||||||
|
if request_count >= self.burst_limit:
|
||||||
|
logger.warning(
|
||||||
|
"请求被限流",
|
||||||
|
client_ip=client_ip,
|
||||||
|
request_count=request_count,
|
||||||
|
path=request.url.path,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={
|
||||||
|
"code": 429,
|
||||||
|
"message": "请求过于频繁,请稍后再试",
|
||||||
|
"retry_after": 60,
|
||||||
|
},
|
||||||
|
headers={"Retry-After": "60"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录本次请求
|
||||||
|
self.request_counts[client_ip].append(now)
|
||||||
|
|
||||||
|
# 如果接近限制,添加警告头
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
remaining = self.burst_limit - len(self.request_counts[client_ip])
|
||||||
|
response.headers["X-RateLimit-Limit"] = str(self.burst_limit)
|
||||||
|
response.headers["X-RateLimit-Remaining"] = str(max(0, remaining))
|
||||||
|
response.headers["X-RateLimit-Reset"] = str(int((window_start + timedelta(minutes=1)).timestamp()))
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""
|
||||||
|
安全响应头中间件
|
||||||
|
|
||||||
|
添加各种安全相关的 HTTP 响应头
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
# 防止 MIME 类型嗅探
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
|
||||||
|
# 防止点击劫持
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
|
||||||
|
# XSS 过滤器(现代浏览器已弃用,但仍有一些旧浏览器支持)
|
||||||
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||||
|
|
||||||
|
# 引用策略
|
||||||
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
|
||||||
|
# 权限策略(禁用一些敏感功能)
|
||||||
|
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
||||||
|
|
||||||
|
# 缓存控制(API 响应不应被缓存)
|
||||||
|
if request.url.path.startswith("/api/"):
|
||||||
|
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class RequestIDMiddleware(BaseHTTPMiddleware):
|
class RequestIDMiddleware(BaseHTTPMiddleware):
|
||||||
"""请求ID中间件"""
|
"""请求ID中间件"""
|
||||||
|
|
||||||
|
|||||||
136
backend/app/core/sanitize.py
Normal file
136
backend/app/core/sanitize.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
输入清理和XSS防护工具
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import html
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# 危险的HTML标签和属性
|
||||||
|
DANGEROUS_TAGS = [
|
||||||
|
'script', 'iframe', 'object', 'embed', 'form', 'input',
|
||||||
|
'textarea', 'button', 'select', 'style', 'link', 'meta',
|
||||||
|
'base', 'applet', 'frame', 'frameset', 'layer', 'ilayer',
|
||||||
|
'bgsound', 'xml', 'blink', 'marquee'
|
||||||
|
]
|
||||||
|
|
||||||
|
DANGEROUS_ATTRS = [
|
||||||
|
'onclick', 'ondblclick', 'onmousedown', 'onmouseup', 'onmouseover',
|
||||||
|
'onmousemove', 'onmouseout', 'onkeypress', 'onkeydown', 'onkeyup',
|
||||||
|
'onload', 'onerror', 'onabort', 'onblur', 'onchange', 'onfocus',
|
||||||
|
'onreset', 'onsubmit', 'onunload', 'onbeforeunload', 'onresize',
|
||||||
|
'onscroll', 'ondrag', 'ondragend', 'ondragenter', 'ondragleave',
|
||||||
|
'ondragover', 'ondragstart', 'ondrop', 'onmousewheel', 'onwheel',
|
||||||
|
'oncopy', 'oncut', 'onpaste', 'oncontextmenu', 'oninput', 'oninvalid',
|
||||||
|
'onsearch', 'onselect', 'ontoggle', 'formaction', 'xlink:href'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_html(text: Optional[str]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
清理HTML内容,移除危险标签和属性
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 输入文本
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
清理后的文本
|
||||||
|
"""
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return text
|
||||||
|
|
||||||
|
result = text
|
||||||
|
|
||||||
|
# 移除危险标签
|
||||||
|
for tag in DANGEROUS_TAGS:
|
||||||
|
# 移除开标签
|
||||||
|
pattern = re.compile(rf'<{tag}[^>]*>', re.IGNORECASE)
|
||||||
|
result = pattern.sub('', result)
|
||||||
|
# 移除闭标签
|
||||||
|
pattern = re.compile(rf'</{tag}>', re.IGNORECASE)
|
||||||
|
result = pattern.sub('', result)
|
||||||
|
|
||||||
|
# 移除危险属性
|
||||||
|
for attr in DANGEROUS_ATTRS:
|
||||||
|
pattern = re.compile(rf'\s*{attr}\s*=\s*["\'][^"\']*["\']', re.IGNORECASE)
|
||||||
|
result = pattern.sub('', result)
|
||||||
|
# 也处理没有引号的情况
|
||||||
|
pattern = re.compile(rf'\s*{attr}\s*=\s*\S+', re.IGNORECASE)
|
||||||
|
result = pattern.sub('', result)
|
||||||
|
|
||||||
|
# 移除 javascript: 协议
|
||||||
|
pattern = re.compile(r'javascript\s*:', re.IGNORECASE)
|
||||||
|
result = pattern.sub('', result)
|
||||||
|
|
||||||
|
# 移除 data: 协议(可能包含恶意代码)
|
||||||
|
pattern = re.compile(r'data\s*:\s*text/html', re.IGNORECASE)
|
||||||
|
result = pattern.sub('', result)
|
||||||
|
|
||||||
|
# 移除 vbscript: 协议
|
||||||
|
pattern = re.compile(r'vbscript\s*:', re.IGNORECASE)
|
||||||
|
result = pattern.sub('', result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def escape_html(text: Optional[str]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
转义HTML特殊字符
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 输入文本
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
转义后的文本
|
||||||
|
"""
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return text
|
||||||
|
|
||||||
|
return html.escape(text, quote=True)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_tags(text: Optional[str]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
完全移除所有HTML标签
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 输入文本
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
移除标签后的纯文本
|
||||||
|
"""
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return text
|
||||||
|
|
||||||
|
# 移除所有HTML标签
|
||||||
|
clean = re.compile('<[^>]*>')
|
||||||
|
return clean.sub('', text)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_input(text: Optional[str], strict: bool = False) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
清理用户输入
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 输入文本
|
||||||
|
strict: 是否使用严格模式(完全移除所有HTML标签)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
清理后的文本
|
||||||
|
"""
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if strict:
|
||||||
|
return strip_tags(text)
|
||||||
|
else:
|
||||||
|
return sanitize_html(text)
|
||||||
@@ -97,6 +97,17 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 添加限流中间件
|
||||||
|
from app.core.middleware import RateLimitMiddleware, SecurityHeadersMiddleware
|
||||||
|
app.add_middleware(
|
||||||
|
RateLimitMiddleware,
|
||||||
|
requests_per_minute=120, # 每分钟最大请求数
|
||||||
|
burst_limit=200, # 突发请求限制
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加安全响应头中间件
|
||||||
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
|
||||||
|
|
||||||
# 健康检查端点
|
# 健康检查端点
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -140,16 +151,60 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
|||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
content={
|
content={
|
||||||
|
"code": 422,
|
||||||
|
"message": "请求参数验证失败",
|
||||||
"detail": exc.errors(),
|
"detail": exc.errors(),
|
||||||
"body": exc.body if hasattr(exc, 'body') else None,
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# JSON 解析错误处理
|
||||||
|
from json import JSONDecodeError
|
||||||
|
@app.exception_handler(JSONDecodeError)
|
||||||
|
async def json_decode_exception_handler(request: Request, exc: JSONDecodeError):
|
||||||
|
"""处理 JSON 解析错误"""
|
||||||
|
logger.warning(f"JSON解析错误 [{request.method} {request.url.path}]: {exc}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={
|
||||||
|
"code": 400,
|
||||||
|
"message": "请求体格式错误,需要有效的 JSON",
|
||||||
|
"detail": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# HTTP 异常处理
|
||||||
|
from fastapi import HTTPException
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||||
|
"""处理 HTTP 异常"""
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={
|
||||||
|
"code": exc.status_code,
|
||||||
|
"message": exc.detail,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# 全局异常处理
|
# 全局异常处理
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def global_exception_handler(request, exc):
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
"""全局异常处理"""
|
"""全局异常处理"""
|
||||||
|
error_msg = str(exc)
|
||||||
|
|
||||||
|
# 检查是否是 Content-Type 相关错误
|
||||||
|
if "Expecting value" in error_msg or "JSON" in error_msg.upper():
|
||||||
|
logger.warning(f"请求体解析错误 [{request.method} {request.url.path}]: {error_msg}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={
|
||||||
|
"code": 400,
|
||||||
|
"message": "请求体格式错误,请使用 application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
logger.error(f"未处理的异常: {exc}", exc_info=True)
|
logger.error(f"未处理的异常: {exc}", exc_info=True)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
课程相关数据库模型
|
课程相关数据库模型
|
||||||
"""
|
"""
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, Optional
|
from typing import List, Optional, TYPE_CHECKING
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.growth_path import GrowthPathNode
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
成长路径相关数据库模型
|
成长路径相关数据库模型
|
||||||
"""
|
"""
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, Optional
|
from typing import List, Optional, TYPE_CHECKING
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.course import Course
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
@@ -84,7 +88,7 @@ class GrowthPathNode(BaseModel, SoftDeleteMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 关联关系
|
# 关联关系
|
||||||
growth_path: Mapped["GrowthPath"] = relationship(
|
growth_path: Mapped["GrowthPath"] = relationship( # noqa: F821
|
||||||
"GrowthPath", back_populates="nodes"
|
"GrowthPath", back_populates="nodes"
|
||||||
)
|
)
|
||||||
course: Mapped["Course"] = relationship("Course")
|
course: Mapped["Course"] = relationship("Course")
|
||||||
@@ -146,7 +150,7 @@ class UserGrowthPathProgress(BaseModel):
|
|||||||
|
|
||||||
# 关联关系
|
# 关联关系
|
||||||
user: Mapped["User"] = relationship("User")
|
user: Mapped["User"] = relationship("User")
|
||||||
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath")
|
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath") # noqa: F821
|
||||||
|
|
||||||
|
|
||||||
class UserNodeCompletion(BaseModel):
|
class UserNodeCompletion(BaseModel):
|
||||||
@@ -203,4 +207,4 @@ class UserNodeCompletion(BaseModel):
|
|||||||
node: Mapped["GrowthPathNode"] = relationship(
|
node: Mapped["GrowthPathNode"] = relationship(
|
||||||
"GrowthPathNode", back_populates="user_completions"
|
"GrowthPathNode", back_populates="user_completions"
|
||||||
)
|
)
|
||||||
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath")
|
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath") # noqa: F821
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from enum import Enum
|
|||||||
from pydantic import BaseModel, Field, ConfigDict, field_validator
|
from pydantic import BaseModel, Field, ConfigDict, field_validator
|
||||||
|
|
||||||
from app.models.course import CourseStatus, CourseCategory
|
from app.models.course import CourseStatus, CourseCategory
|
||||||
|
from app.core.sanitize import sanitize_input
|
||||||
|
|
||||||
|
|
||||||
class CourseBase(BaseModel):
|
class CourseBase(BaseModel):
|
||||||
@@ -26,6 +27,18 @@ class CourseBase(BaseModel):
|
|||||||
is_featured: bool = Field(default=False, description="是否推荐")
|
is_featured: bool = Field(default=False, description="是否推荐")
|
||||||
allow_download: bool = Field(default=False, description="是否允许下载资料")
|
allow_download: bool = Field(default=False, description="是否允许下载资料")
|
||||||
|
|
||||||
|
@field_validator("name", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def sanitize_name(cls, v):
|
||||||
|
"""清理课程名称中的XSS内容"""
|
||||||
|
return sanitize_input(v, strict=True) if v else v
|
||||||
|
|
||||||
|
@field_validator("description", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def sanitize_description(cls, v):
|
||||||
|
"""清理课程描述中的XSS内容"""
|
||||||
|
return sanitize_input(v, strict=False) if v else v
|
||||||
|
|
||||||
@field_validator("category", mode="before")
|
@field_validator("category", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def normalize_category(cls, v):
|
def normalize_category(cls, v):
|
||||||
@@ -75,6 +88,18 @@ class CourseUpdate(BaseModel):
|
|||||||
is_featured: Optional[bool] = Field(None, description="是否推荐")
|
is_featured: Optional[bool] = Field(None, description="是否推荐")
|
||||||
allow_download: Optional[bool] = Field(None, description="是否允许下载资料")
|
allow_download: Optional[bool] = Field(None, description="是否允许下载资料")
|
||||||
|
|
||||||
|
@field_validator("name", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def sanitize_name_update(cls, v):
|
||||||
|
"""清理课程名称中的XSS内容"""
|
||||||
|
return sanitize_input(v, strict=True) if v else v
|
||||||
|
|
||||||
|
@field_validator("description", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def sanitize_description_update(cls, v):
|
||||||
|
"""清理课程描述中的XSS内容"""
|
||||||
|
return sanitize_input(v, strict=False) if v else v
|
||||||
|
|
||||||
@field_validator("category", mode="before")
|
@field_validator("category", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def normalize_category_update(cls, v):
|
def normalize_category_update(cls, v):
|
||||||
@@ -150,15 +175,15 @@ class CourseMaterialCreate(CourseMaterialBase):
|
|||||||
@field_validator("file_type")
|
@field_validator("file_type")
|
||||||
def validate_file_type(cls, v):
|
def validate_file_type(cls, v):
|
||||||
"""验证文件类型
|
"""验证文件类型
|
||||||
支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties
|
支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、PPT、CSV、VTT、Properties
|
||||||
"""
|
"""
|
||||||
allowed_types = [
|
allowed_types = [
|
||||||
"txt", "md", "mdx", "pdf", "html", "htm",
|
"txt", "md", "mdx", "pdf", "html", "htm",
|
||||||
"xlsx", "xls", "docx", "doc", "csv", "vtt", "properties"
|
"xlsx", "xls", "docx", "doc", "pptx", "ppt", "csv", "vtt", "properties"
|
||||||
]
|
]
|
||||||
file_ext = v.lower()
|
file_ext = v.lower()
|
||||||
if file_ext not in allowed_types:
|
if file_ext not in allowed_types:
|
||||||
raise ValueError(f"不支持的文件类型: {v}。允许的类型: TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties")
|
raise ValueError(f"不支持的文件类型: {v}。允许的类型: TXT、Markdown、MDX、PDF、HTML、Excel、Word、PPT、CSV、VTT、Properties")
|
||||||
return file_ext
|
return file_ext
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ class RecentExamItem(BaseModel):
|
|||||||
total_score: float = Field(..., description="总分")
|
total_score: float = Field(..., description="总分")
|
||||||
is_passed: Optional[bool] = Field(None, description="是否通过")
|
is_passed: Optional[bool] = Field(None, description="是否通过")
|
||||||
duration_seconds: Optional[int] = Field(None, description="考试用时(秒)")
|
duration_seconds: Optional[int] = Field(None, description="考试用时(秒)")
|
||||||
start_time: str = Field(..., description="开始时间")
|
start_time: Optional[str] = Field(None, description="开始时间")
|
||||||
end_time: Optional[str] = Field(None, description="结束时间")
|
end_time: Optional[str] = Field(None, description="结束时间")
|
||||||
round_scores: RoundScores = Field(..., description="三轮得分")
|
round_scores: RoundScores = Field(..., description="三轮得分")
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class UserCreate(UserBase):
|
|||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseSchema):
|
class UserUpdate(BaseSchema):
|
||||||
"""更新用户"""
|
"""更新用户(管理员使用)"""
|
||||||
|
|
||||||
email: Optional[EmailStr] = None
|
email: Optional[EmailStr] = None
|
||||||
phone: Optional[str] = Field(None, pattern=r"^1[3-9]\d{9}$")
|
phone: Optional[str] = Field(None, pattern=r"^1[3-9]\d{9}$")
|
||||||
@@ -52,6 +52,19 @@ class UserUpdate(BaseSchema):
|
|||||||
major: Optional[str] = Field(None, max_length=100)
|
major: Optional[str] = Field(None, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class UserSelfUpdate(BaseSchema):
|
||||||
|
"""用户自己更新个人信息(不允许修改role和is_active)"""
|
||||||
|
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
phone: Optional[str] = Field(None, pattern=r"^1[3-9]\d{9}$")
|
||||||
|
full_name: Optional[str] = Field(None, max_length=100)
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
bio: Optional[str] = None
|
||||||
|
gender: Optional[str] = Field(None, pattern="^(male|female)$")
|
||||||
|
school: Optional[str] = Field(None, max_length=100)
|
||||||
|
major: Optional[str] = Field(None, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
class UserPasswordUpdate(BaseSchema):
|
class UserPasswordUpdate(BaseSchema):
|
||||||
"""更新密码"""
|
"""更新密码"""
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ class KnowledgeAnalysisServiceV2:
|
|||||||
"""
|
"""
|
||||||
提取文档内容
|
提取文档内容
|
||||||
|
|
||||||
支持:PDF、Word(docx)、Excel(xlsx/xls)、文本文件
|
支持:PDF、Word(docx)、Excel(xlsx/xls)、PPT(pptx/ppt)、文本文件
|
||||||
"""
|
"""
|
||||||
suffix = file_path.suffix.lower()
|
suffix = file_path.suffix.lower()
|
||||||
|
|
||||||
@@ -187,6 +187,8 @@ class KnowledgeAnalysisServiceV2:
|
|||||||
return await self._extract_docx_content(file_path)
|
return await self._extract_docx_content(file_path)
|
||||||
elif suffix in ['.xlsx', '.xls']:
|
elif suffix in ['.xlsx', '.xls']:
|
||||||
return await self._extract_excel_content(file_path)
|
return await self._extract_excel_content(file_path)
|
||||||
|
elif suffix in ['.pptx', '.ppt']:
|
||||||
|
return await self._extract_ppt_content(file_path)
|
||||||
elif suffix in ['.txt', '.md', '.text']:
|
elif suffix in ['.txt', '.md', '.text']:
|
||||||
return await self._extract_text_content(file_path)
|
return await self._extract_text_content(file_path)
|
||||||
else:
|
else:
|
||||||
@@ -303,6 +305,49 @@ class KnowledgeAnalysisServiceV2:
|
|||||||
logger.error(f"Excel 文件读取失败: {e}")
|
logger.error(f"Excel 文件读取失败: {e}")
|
||||||
raise ValueError(f"Excel 文件读取失败: {e}")
|
raise ValueError(f"Excel 文件读取失败: {e}")
|
||||||
|
|
||||||
|
async def _extract_ppt_content(self, file_path: Path) -> str:
|
||||||
|
"""提取 PowerPoint 文件内容"""
|
||||||
|
try:
|
||||||
|
from pptx import Presentation
|
||||||
|
from pptx.util import Inches
|
||||||
|
|
||||||
|
prs = Presentation(str(file_path))
|
||||||
|
text_parts = []
|
||||||
|
|
||||||
|
for slide_num, slide in enumerate(prs.slides, 1):
|
||||||
|
slide_texts = []
|
||||||
|
text_parts.append(f"【幻灯片 {slide_num}】")
|
||||||
|
|
||||||
|
for shape in slide.shapes:
|
||||||
|
# 提取文本框内容
|
||||||
|
if hasattr(shape, "text") and shape.text.strip():
|
||||||
|
slide_texts.append(shape.text.strip())
|
||||||
|
|
||||||
|
# 提取表格内容
|
||||||
|
if shape.has_table:
|
||||||
|
table = shape.table
|
||||||
|
for row in table.rows:
|
||||||
|
row_text = ' | '.join(
|
||||||
|
cell.text.strip() for cell in row.cells if cell.text.strip()
|
||||||
|
)
|
||||||
|
if row_text:
|
||||||
|
slide_texts.append(row_text)
|
||||||
|
|
||||||
|
if slide_texts:
|
||||||
|
text_parts.append('\n'.join(slide_texts))
|
||||||
|
else:
|
||||||
|
text_parts.append("(无文本内容)")
|
||||||
|
|
||||||
|
content = '\n\n'.join(text_parts)
|
||||||
|
return self._clean_content(content)
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.error("python-pptx 未安装,无法读取 PPT 文件")
|
||||||
|
raise ValueError("服务器未安装 PPT 读取组件(python-pptx)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"PPT 文件读取失败: {e}")
|
||||||
|
raise ValueError(f"PPT 文件读取失败: {e}")
|
||||||
|
|
||||||
def _clean_content(self, content: str) -> str:
|
def _clean_content(self, content: str) -> str:
|
||||||
"""清理和截断内容"""
|
"""清理和截断内容"""
|
||||||
# 移除多余空白
|
# 移除多余空白
|
||||||
|
|||||||
276
backend/app/services/dingtalk_service.py
Normal file
276
backend/app/services/dingtalk_service.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
"""
|
||||||
|
钉钉开放平台 API 服务
|
||||||
|
用于通过钉钉 API 获取组织架构和员工信息
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DingTalkService:
|
||||||
|
"""钉钉 API 服务"""
|
||||||
|
|
||||||
|
BASE_URL = "https://api.dingtalk.com"
|
||||||
|
OAPI_URL = "https://oapi.dingtalk.com"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
corp_id: str,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
初始化钉钉服务
|
||||||
|
|
||||||
|
Args:
|
||||||
|
corp_id: 企业 CorpId
|
||||||
|
client_id: 应用 ClientId (AppKey)
|
||||||
|
client_secret: 应用 ClientSecret (AppSecret)
|
||||||
|
"""
|
||||||
|
self.corp_id = corp_id
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
|
self._access_token: Optional[str] = None
|
||||||
|
self._token_expires_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
async def get_access_token(self) -> str:
|
||||||
|
"""
|
||||||
|
获取钉钉 Access Token
|
||||||
|
|
||||||
|
使用新版 OAuth2 接口获取
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
access_token
|
||||||
|
"""
|
||||||
|
# 检查缓存的 token 是否有效
|
||||||
|
if self._access_token and self._token_expires_at:
|
||||||
|
if datetime.now() < self._token_expires_at - timedelta(minutes=5):
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
url = f"{self.BASE_URL}/v1.0/oauth2/{self.corp_id}/token"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"grant_type": "client_credentials"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
self._access_token = data["access_token"]
|
||||||
|
expires_in = data.get("expires_in", 7200)
|
||||||
|
self._token_expires_at = datetime.now() + timedelta(seconds=expires_in)
|
||||||
|
|
||||||
|
logger.info(f"获取钉钉 Access Token 成功,有效期 {expires_in} 秒")
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
async def get_department_list(self, dept_id: int = 1) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取部门列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dept_id: 父部门ID,根部门为1
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
部门列表
|
||||||
|
"""
|
||||||
|
access_token = await self.get_access_token()
|
||||||
|
url = f"{self.OAPI_URL}/topapi/v2/department/listsub"
|
||||||
|
|
||||||
|
params = {"access_token": access_token}
|
||||||
|
payload = {"dept_id": dept_id}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.post(url, params=params, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("errcode") != 0:
|
||||||
|
raise Exception(f"获取部门列表失败: {data.get('errmsg')}")
|
||||||
|
|
||||||
|
return data.get("result", [])
|
||||||
|
|
||||||
|
async def get_all_departments(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
递归获取所有部门
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
所有部门列表(扁平化)
|
||||||
|
"""
|
||||||
|
all_departments = []
|
||||||
|
|
||||||
|
async def fetch_recursive(parent_id: int):
|
||||||
|
departments = await self.get_department_list(parent_id)
|
||||||
|
for dept in departments:
|
||||||
|
all_departments.append(dept)
|
||||||
|
# 递归获取子部门
|
||||||
|
await fetch_recursive(dept["dept_id"])
|
||||||
|
|
||||||
|
await fetch_recursive(1) # 从根部门开始
|
||||||
|
logger.info(f"获取到 {len(all_departments)} 个部门")
|
||||||
|
return all_departments
|
||||||
|
|
||||||
|
async def get_department_users(
|
||||||
|
self,
|
||||||
|
dept_id: int,
|
||||||
|
cursor: int = 0,
|
||||||
|
size: int = 100
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取部门用户列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dept_id: 部门ID
|
||||||
|
cursor: 分页游标
|
||||||
|
size: 每页大小,最大100
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
用户列表和分页信息
|
||||||
|
"""
|
||||||
|
access_token = await self.get_access_token()
|
||||||
|
url = f"{self.OAPI_URL}/topapi/v2/user/list"
|
||||||
|
|
||||||
|
params = {"access_token": access_token}
|
||||||
|
payload = {
|
||||||
|
"dept_id": dept_id,
|
||||||
|
"cursor": cursor,
|
||||||
|
"size": size
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.post(url, params=params, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("errcode") != 0:
|
||||||
|
raise Exception(f"获取部门用户失败: {data.get('errmsg')}")
|
||||||
|
|
||||||
|
return data.get("result", {})
|
||||||
|
|
||||||
|
async def get_all_employees(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取所有在职员工
|
||||||
|
|
||||||
|
遍历所有部门获取员工列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
员工列表
|
||||||
|
"""
|
||||||
|
logger.info("开始从钉钉 API 获取所有员工...")
|
||||||
|
|
||||||
|
# 1. 获取所有部门
|
||||||
|
departments = await self.get_all_departments()
|
||||||
|
|
||||||
|
# 创建部门ID到名称的映射
|
||||||
|
dept_map = {dept["dept_id"]: dept["name"] for dept in departments}
|
||||||
|
dept_map[1] = "根部门" # 添加根部门
|
||||||
|
|
||||||
|
# 2. 遍历所有部门获取员工
|
||||||
|
all_employees = {} # 使用字典去重(按 userid)
|
||||||
|
|
||||||
|
for dept in [{"dept_id": 1, "name": "根部门"}] + departments:
|
||||||
|
dept_id = dept["dept_id"]
|
||||||
|
dept_name = dept["name"]
|
||||||
|
|
||||||
|
cursor = 0
|
||||||
|
while True:
|
||||||
|
result = await self.get_department_users(dept_id, cursor)
|
||||||
|
users = result.get("list", [])
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
userid = user.get("userid")
|
||||||
|
if userid and userid not in all_employees:
|
||||||
|
# 转换为统一格式
|
||||||
|
employee = self._convert_user_to_employee(user, dept_name)
|
||||||
|
all_employees[userid] = employee
|
||||||
|
|
||||||
|
# 检查是否还有更多数据
|
||||||
|
if not result.get("has_more", False):
|
||||||
|
break
|
||||||
|
cursor = result.get("next_cursor", 0)
|
||||||
|
|
||||||
|
employees = list(all_employees.values())
|
||||||
|
logger.info(f"获取到 {len(employees)} 位在职员工")
|
||||||
|
return employees
|
||||||
|
|
||||||
|
def _convert_user_to_employee(
|
||||||
|
self,
|
||||||
|
user: Dict[str, Any],
|
||||||
|
dept_name: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
将钉钉用户数据转换为员工数据格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: 钉钉用户数据
|
||||||
|
dept_name: 部门名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
标准员工数据格式
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'full_name': user.get('name', ''),
|
||||||
|
'phone': user.get('mobile', ''),
|
||||||
|
'email': user.get('email', ''),
|
||||||
|
'department': dept_name,
|
||||||
|
'position': user.get('title', ''),
|
||||||
|
'employee_no': user.get('job_number', ''),
|
||||||
|
'is_leader': user.get('leader', False),
|
||||||
|
'is_active': user.get('active', True),
|
||||||
|
'dingtalk_id': user.get('userid', ''),
|
||||||
|
'join_date': user.get('hired_date'),
|
||||||
|
'work_location': user.get('work_place', ''),
|
||||||
|
'avatar': user.get('avatar', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_connection(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
测试钉钉 API 连接
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
测试结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. 测试获取 token
|
||||||
|
token = await self.get_access_token()
|
||||||
|
|
||||||
|
# 2. 测试获取根部门信息
|
||||||
|
departments = await self.get_department_list(1)
|
||||||
|
|
||||||
|
# 3. 获取根部门员工数量
|
||||||
|
result = await self.get_department_users(1, size=1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "连接成功",
|
||||||
|
"corp_id": self.corp_id,
|
||||||
|
"department_count": len(departments) + 1, # +1 是根部门
|
||||||
|
"has_employees": result.get("has_more", False) or len(result.get("list", [])) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
error_detail = "HTTP错误"
|
||||||
|
if e.response.status_code == 400:
|
||||||
|
try:
|
||||||
|
error_data = e.response.json()
|
||||||
|
error_detail = error_data.get("message", str(e))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"连接失败: {error_detail}",
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"连接失败: {str(e)}",
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
员工同步服务
|
员工同步服务
|
||||||
从外部钉钉员工表同步员工数据到考培练系统
|
从钉钉开放 API 同步员工数据到考培练系统
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List, Dict, Any, Optional, Tuple
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
@@ -23,77 +23,86 @@ logger = get_logger(__name__)
|
|||||||
class EmployeeSyncService:
|
class EmployeeSyncService:
|
||||||
"""员工同步服务"""
|
"""员工同步服务"""
|
||||||
|
|
||||||
# 外部数据库连接配置
|
def __init__(self, db: AsyncSession, tenant_id: int = 1):
|
||||||
EXTERNAL_DB_URL = "mysql+aiomysql://neuron_new:NWxGM6CQoMLKyEszXhfuLBIIo1QbeK@120.77.144.233:29613/neuron_new?charset=utf8mb4"
|
|
||||||
|
|
||||||
def __init__(self, db: AsyncSession):
|
|
||||||
self.db = db
|
self.db = db
|
||||||
self.external_engine = None
|
self.tenant_id = tenant_id
|
||||||
|
self._dingtalk_config = None
|
||||||
|
|
||||||
|
async def _get_dingtalk_config(self) -> Dict[str, str]:
|
||||||
|
"""从数据库获取钉钉 API 配置(复用免密登录配置)"""
|
||||||
|
if self._dingtalk_config:
|
||||||
|
return self._dingtalk_config
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 从 dingtalk 配置组读取(与免密登录共用)
|
||||||
|
result = await self.db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT config_key, config_value
|
||||||
|
FROM tenant_configs
|
||||||
|
WHERE tenant_id = :tenant_id
|
||||||
|
AND config_group = 'dingtalk'
|
||||||
|
"""),
|
||||||
|
{"tenant_id": self.tenant_id}
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
config = {}
|
||||||
|
for key, value in rows:
|
||||||
|
# 转换 key 名称以匹配 DingTalkService 需要的格式
|
||||||
|
if key == 'DINGTALK_CORP_ID':
|
||||||
|
config['CORP_ID'] = value
|
||||||
|
elif key == 'DINGTALK_APP_KEY':
|
||||||
|
config['CLIENT_ID'] = value
|
||||||
|
elif key == 'DINGTALK_APP_SECRET':
|
||||||
|
config['CLIENT_SECRET'] = value
|
||||||
|
|
||||||
|
self._dingtalk_config = config
|
||||||
|
return config
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取钉钉配置失败: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
"""异步上下文管理器入口"""
|
"""异步上下文管理器入口"""
|
||||||
self.external_engine = create_async_engine(
|
# 预加载钉钉配置
|
||||||
self.EXTERNAL_DB_URL,
|
await self._get_dingtalk_config()
|
||||||
echo=False,
|
|
||||||
pool_pre_ping=True,
|
|
||||||
pool_recycle=3600
|
|
||||||
)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
"""异步上下文管理器出口"""
|
"""异步上下文管理器出口"""
|
||||||
if self.external_engine:
|
pass
|
||||||
await self.external_engine.dispose()
|
|
||||||
|
|
||||||
async def fetch_employees_from_dingtalk(self) -> List[Dict[str, Any]]:
|
async def fetch_employees_from_dingtalk(self) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
从钉钉员工表获取在职员工数据
|
从钉钉 API 获取在职员工数据
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
员工数据列表
|
员工数据列表
|
||||||
"""
|
"""
|
||||||
logger.info("开始从钉钉员工表获取数据...")
|
config = await self._get_dingtalk_config()
|
||||||
|
|
||||||
query = """
|
corp_id = config.get('CORP_ID')
|
||||||
SELECT
|
client_id = config.get('CLIENT_ID')
|
||||||
员工姓名,
|
client_secret = config.get('CLIENT_SECRET')
|
||||||
手机号,
|
|
||||||
邮箱,
|
|
||||||
所属部门,
|
|
||||||
职位,
|
|
||||||
工号,
|
|
||||||
是否领导,
|
|
||||||
是否在职,
|
|
||||||
钉钉用户ID,
|
|
||||||
入职日期,
|
|
||||||
工作地点
|
|
||||||
FROM v_钉钉员工表
|
|
||||||
WHERE 是否在职 = 1
|
|
||||||
ORDER BY 员工姓名
|
|
||||||
"""
|
|
||||||
|
|
||||||
async with self.external_engine.connect() as conn:
|
if not all([corp_id, client_id, client_secret]):
|
||||||
result = await conn.execute(text(query))
|
raise Exception("钉钉 API 配置不完整,请先配置 CorpId、ClientId、ClientSecret")
|
||||||
rows = result.fetchall()
|
|
||||||
|
|
||||||
employees = []
|
from app.services.dingtalk_service import DingTalkService
|
||||||
for row in rows:
|
|
||||||
employees.append({
|
|
||||||
'full_name': row[0],
|
|
||||||
'phone': row[1],
|
|
||||||
'email': row[2],
|
|
||||||
'department': row[3],
|
|
||||||
'position': row[4],
|
|
||||||
'employee_no': row[5],
|
|
||||||
'is_leader': bool(row[6]),
|
|
||||||
'is_active': bool(row[7]),
|
|
||||||
'dingtalk_id': row[8],
|
|
||||||
'join_date': row[9],
|
|
||||||
'work_location': row[10]
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(f"获取到 {len(employees)} 条在职员工数据")
|
dingtalk = DingTalkService(
|
||||||
return employees
|
corp_id=corp_id,
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret
|
||||||
|
)
|
||||||
|
|
||||||
|
employees = await dingtalk.get_all_employees()
|
||||||
|
|
||||||
|
# 过滤在职员工
|
||||||
|
active_employees = [emp for emp in employees if emp.get('is_active', True)]
|
||||||
|
logger.info(f"获取到 {len(active_employees)} 条在职员工数据")
|
||||||
|
|
||||||
|
return active_employees
|
||||||
|
|
||||||
def generate_email(self, phone: str, original_email: Optional[str]) -> Optional[str]:
|
def generate_email(self, phone: str, original_email: Optional[str]) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from sqlalchemy import select, func, and_, or_, desc
|
|||||||
from app.models.exam import Exam, Question, ExamResult
|
from app.models.exam import Exam, Question, ExamResult
|
||||||
from app.models.exam_mistake import ExamMistake
|
from app.models.exam_mistake import ExamMistake
|
||||||
from app.models.course import Course, KnowledgePoint
|
from app.models.course import Course, KnowledgePoint
|
||||||
from app.core.exceptions import BusinessException, ErrorCode
|
from app.core.exceptions import NotFoundError, ValidationError
|
||||||
from app.utils.score_distributor import ScoreDistributor
|
from app.utils.score_distributor import ScoreDistributor
|
||||||
|
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ class ExamService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def start_exam(
|
async def start_exam(
|
||||||
db: AsyncSession, user_id: int, course_id: int, question_count: int = 10
|
db: AsyncSession, user_id: int, course_id: int, question_count: int = 10
|
||||||
) -> Exam:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
开始考试
|
开始考试
|
||||||
|
|
||||||
@@ -31,12 +31,12 @@ class ExamService:
|
|||||||
question_count: 题目数量
|
question_count: 题目数量
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Exam: 考试实例
|
int: 考试ID
|
||||||
"""
|
"""
|
||||||
# 检查课程是否存在
|
# 检查课程是否存在
|
||||||
course = await db.get(Course, course_id)
|
course = await db.get(Course, course_id)
|
||||||
if not course:
|
if not course:
|
||||||
raise BusinessException(error_code=ErrorCode.NOT_FOUND, message="课程不存在")
|
raise NotFoundError("课程不存在")
|
||||||
|
|
||||||
# 获取该课程的所有可用题目
|
# 获取该课程的所有可用题目
|
||||||
stmt = select(Question).where(
|
stmt = select(Question).where(
|
||||||
@@ -46,9 +46,7 @@ class ExamService:
|
|||||||
all_questions = result.scalars().all()
|
all_questions = result.scalars().all()
|
||||||
|
|
||||||
if not all_questions:
|
if not all_questions:
|
||||||
raise BusinessException(
|
raise ValidationError("该课程暂无题目")
|
||||||
error_code=ErrorCode.VALIDATION_ERROR, message="该课程暂无题目"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 随机选择题目
|
# 随机选择题目
|
||||||
selected_questions = random.sample(
|
selected_questions = random.sample(
|
||||||
@@ -96,7 +94,9 @@ class ExamService:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(exam)
|
await db.refresh(exam)
|
||||||
|
|
||||||
return exam
|
# 返回exam.id而不是整个对象,避免懒加载问题
|
||||||
|
exam_id = exam.id
|
||||||
|
return exam_id
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def submit_exam(
|
async def submit_exam(
|
||||||
@@ -120,12 +120,10 @@ class ExamService:
|
|||||||
exam = result.scalar_one_or_none()
|
exam = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not exam:
|
if not exam:
|
||||||
raise BusinessException(error_code=ErrorCode.NOT_FOUND, message="考试记录不存在")
|
raise NotFoundError("考试记录不存在")
|
||||||
|
|
||||||
if exam.status != "started":
|
if exam.status != "started":
|
||||||
raise BusinessException(
|
raise ValidationError("考试已结束或已提交")
|
||||||
error_code=ErrorCode.VALIDATION_ERROR, message="考试已结束或已提交"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 检查考试是否超时
|
# 检查考试是否超时
|
||||||
if datetime.now() > exam.start_time + timedelta(
|
if datetime.now() > exam.start_time + timedelta(
|
||||||
@@ -133,9 +131,7 @@ class ExamService:
|
|||||||
):
|
):
|
||||||
exam.status = "timeout"
|
exam.status = "timeout"
|
||||||
await db.commit()
|
await db.commit()
|
||||||
raise BusinessException(
|
raise ValidationError("考试已超时")
|
||||||
error_code=ErrorCode.VALIDATION_ERROR, message="考试已超时"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 处理答案
|
# 处理答案
|
||||||
answers_dict = {ans["question_id"]: ans["answer"] for ans in answers}
|
answers_dict = {ans["question_id"]: ans["answer"] for ans in answers}
|
||||||
@@ -223,7 +219,7 @@ class ExamService:
|
|||||||
exam = result.scalar_one_or_none()
|
exam = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not exam:
|
if not exam:
|
||||||
raise BusinessException(error_code=ErrorCode.NOT_FOUND, message="考试记录不存在")
|
raise NotFoundError("考试记录不存在")
|
||||||
|
|
||||||
# 构建返回数据
|
# 构建返回数据
|
||||||
exam_data = {
|
exam_data = {
|
||||||
@@ -332,7 +328,9 @@ class ExamService:
|
|||||||
if exam.questions:
|
if exam.questions:
|
||||||
try:
|
try:
|
||||||
# 解析questions JSON,统计每种题型的总数
|
# 解析questions JSON,统计每种题型的总数
|
||||||
questions_data = json.loads(exam.questions) if isinstance(exam.questions, str) else exam.questions
|
questions_raw = json.loads(exam.questions) if isinstance(exam.questions, str) else exam.questions
|
||||||
|
# questions可能是 {"questions": [...]} 或直接是列表
|
||||||
|
questions_data = questions_raw.get("questions", questions_raw) if isinstance(questions_raw, dict) else questions_raw
|
||||||
type_totals = {}
|
type_totals = {}
|
||||||
type_scores = {} # 存储每种题型的总分
|
type_scores = {} # 存储每种题型的总分
|
||||||
|
|
||||||
|
|||||||
@@ -261,8 +261,6 @@ def start_scheduler():
|
|||||||
|
|
||||||
def stop_scheduler():
|
def stop_scheduler():
|
||||||
"""停止调度器"""
|
"""停止调度器"""
|
||||||
global scheduler
|
|
||||||
|
|
||||||
if scheduler and scheduler.running:
|
if scheduler and scheduler.running:
|
||||||
scheduler.shutdown()
|
scheduler.shutdown()
|
||||||
logger.info("定时任务调度器已停止")
|
logger.info("定时任务调度器已停止")
|
||||||
|
|||||||
@@ -51,9 +51,10 @@ openpyxl==3.1.2
|
|||||||
json-repair>=0.25.0
|
json-repair>=0.25.0
|
||||||
jsonschema>=4.0.0
|
jsonschema>=4.0.0
|
||||||
|
|
||||||
# PDF 文档提取
|
# 文档提取
|
||||||
PyPDF2>=3.0.0
|
PyPDF2>=3.0.0
|
||||||
python-docx>=1.0.0
|
python-docx>=1.0.0
|
||||||
|
python-pptx>=0.6.21
|
||||||
|
|
||||||
# 证书生成
|
# 证书生成
|
||||||
Pillow>=10.0.0
|
Pillow>=10.0.0
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ VITE_ENABLE_DEVTOOLS=true
|
|||||||
VITE_ENABLE_ERROR_REPORTING=true
|
VITE_ENABLE_ERROR_REPORTING=true
|
||||||
VITE_ENABLE_ANALYTICS=false
|
VITE_ENABLE_ANALYTICS=false
|
||||||
|
|
||||||
|
# 实验性功能开关(开发环境默认开启)
|
||||||
|
VITE_FEATURE_DUO_PRACTICE=true
|
||||||
|
VITE_FEATURE_AI_PRACTICE=true
|
||||||
|
VITE_FEATURE_GROWTH_PATH=true
|
||||||
|
|
||||||
# 安全配置
|
# 安全配置
|
||||||
VITE_JWT_EXPIRE_TIME=86400
|
VITE_JWT_EXPIRE_TIME=86400
|
||||||
VITE_REFRESH_TOKEN_EXPIRE_TIME=604800
|
VITE_REFRESH_TOKEN_EXPIRE_TIME=604800
|
||||||
|
|||||||
@@ -5,3 +5,8 @@ VITE_WS_BASE_URL=wss://aiedu.ireborn.com.cn
|
|||||||
VITE_USE_MOCK_DATA=false
|
VITE_USE_MOCK_DATA=false
|
||||||
VITE_ENABLE_REQUEST_LOG=false
|
VITE_ENABLE_REQUEST_LOG=false
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# 实验性功能开关(生产环境默认关闭未上线功能)
|
||||||
|
VITE_FEATURE_DUO_PRACTICE=false
|
||||||
|
VITE_FEATURE_AI_PRACTICE=true
|
||||||
|
VITE_FEATURE_GROWTH_PATH=true
|
||||||
|
|||||||
@@ -106,3 +106,10 @@ export function deleteTask(id: number): Promise<ResponseModel<void>> {
|
|||||||
return http.delete(`/api/v1/manager/tasks/${id}`)
|
return http.delete(`/api/v1/manager/tasks/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送任务提醒
|
||||||
|
*/
|
||||||
|
export function sendTaskReminder(id: number): Promise<ResponseModel<void>> {
|
||||||
|
return http.post(`/api/v1/manager/tasks/${id}/remind`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,23 @@ class EnvConfig {
|
|||||||
public readonly ENABLE_ERROR_REPORTING = import.meta.env.VITE_ENABLE_ERROR_REPORTING === 'true'
|
public readonly ENABLE_ERROR_REPORTING = import.meta.env.VITE_ENABLE_ERROR_REPORTING === 'true'
|
||||||
public readonly ENABLE_ANALYTICS = import.meta.env.VITE_ENABLE_ANALYTICS === 'true'
|
public readonly ENABLE_ANALYTICS = import.meta.env.VITE_ENABLE_ANALYTICS === 'true'
|
||||||
|
|
||||||
|
// 实验性功能开关(Feature Flags)
|
||||||
|
public readonly FEATURE_DUO_PRACTICE = import.meta.env.VITE_FEATURE_DUO_PRACTICE === 'true'
|
||||||
|
public readonly FEATURE_AI_PRACTICE = import.meta.env.VITE_FEATURE_AI_PRACTICE !== 'false' // 默认开启
|
||||||
|
public readonly FEATURE_GROWTH_PATH = import.meta.env.VITE_FEATURE_GROWTH_PATH !== 'false' // 默认开启
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查功能是否启用
|
||||||
|
*/
|
||||||
|
public isFeatureEnabled(feature: string): boolean {
|
||||||
|
const featureMap: Record<string, boolean> = {
|
||||||
|
'duo-practice': this.FEATURE_DUO_PRACTICE,
|
||||||
|
'ai-practice': this.FEATURE_AI_PRACTICE,
|
||||||
|
'growth-path': this.FEATURE_GROWTH_PATH
|
||||||
|
}
|
||||||
|
return featureMap[feature] ?? false
|
||||||
|
}
|
||||||
|
|
||||||
// 安全配置
|
// 安全配置
|
||||||
public readonly JWT_EXPIRE_TIME = parseInt(import.meta.env.VITE_JWT_EXPIRE_TIME || '86400') // 24小时
|
public readonly JWT_EXPIRE_TIME = parseInt(import.meta.env.VITE_JWT_EXPIRE_TIME || '86400') // 24小时
|
||||||
public readonly REFRESH_TOKEN_EXPIRE_TIME = parseInt(import.meta.env.VITE_REFRESH_TOKEN_EXPIRE_TIME || '604800') // 7天
|
public readonly REFRESH_TOKEN_EXPIRE_TIME = parseInt(import.meta.env.VITE_REFRESH_TOKEN_EXPIRE_TIME || '604800') // 7天
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ import { useRouter, useRoute } from 'vue-router'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { authManager } from '@/utils/auth'
|
import { authManager } from '@/utils/auth'
|
||||||
import NotificationBell from '@/components/NotificationBell.vue'
|
import NotificationBell from '@/components/NotificationBell.vue'
|
||||||
|
import { env } from '@/config/env'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -261,7 +262,8 @@ const menuConfig = [
|
|||||||
{
|
{
|
||||||
path: '/trainee/duo-practice',
|
path: '/trainee/duo-practice',
|
||||||
title: '双人对练',
|
title: '双人对练',
|
||||||
icon: 'Connection'
|
icon: 'Connection',
|
||||||
|
feature: 'duo-practice' // 功能开关标识
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -377,9 +379,15 @@ const menuConfig = [
|
|||||||
|
|
||||||
// 获取菜单路由
|
// 获取菜单路由
|
||||||
const menuRoutes = computed(() => {
|
const menuRoutes = computed(() => {
|
||||||
// 仅保留当前用户可访问的菜单项
|
// 仅保留当前用户可访问的菜单项和启用的功能
|
||||||
const filterChildren = (children: any[] = []) =>
|
const filterChildren = (children: any[] = []) =>
|
||||||
children.filter((child: any) => authManager.canAccessRoute(child.path))
|
children.filter((child: any) => {
|
||||||
|
// 检查权限
|
||||||
|
if (!authManager.canAccessRoute(child.path)) return false
|
||||||
|
// 检查功能开关
|
||||||
|
if (child.feature && !env.isFeatureEnabled(child.feature)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
return menuConfig
|
return menuConfig
|
||||||
.map((route: any) => {
|
.map((route: any) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ElMessage } from 'element-plus'
|
|||||||
import { authManager } from '@/utils/auth'
|
import { authManager } from '@/utils/auth'
|
||||||
import { loadingManager } from '@/utils/loadingManager'
|
import { loadingManager } from '@/utils/loadingManager'
|
||||||
import { checkTeamMembership, checkCourseAccess, clearPermissionCache } from '@/utils/permissionChecker'
|
import { checkTeamMembership, checkCourseAccess, clearPermissionCache } from '@/utils/permissionChecker'
|
||||||
|
import { env } from '@/config/env'
|
||||||
|
|
||||||
// 白名单路由(不需要登录)
|
// 白名单路由(不需要登录)
|
||||||
const WHITE_LIST = ['/login', '/register', '/404']
|
const WHITE_LIST = ['/login', '/register', '/404']
|
||||||
@@ -102,6 +103,14 @@ async function handleRouteGuard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查功能开关
|
||||||
|
const feature = to.meta?.feature as string | undefined
|
||||||
|
if (feature && !env.isFeatureEnabled(feature)) {
|
||||||
|
ElMessage.warning('此功能暂未开放')
|
||||||
|
next(authManager.getDefaultRoute())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 检查路由权限
|
// 检查路由权限
|
||||||
if (!checkRoutePermission(path)) {
|
if (!checkRoutePermission(path)) {
|
||||||
ElMessage.error('您没有访问此页面的权限')
|
ElMessage.error('您没有访问此页面的权限')
|
||||||
@@ -302,5 +311,6 @@ declare module 'vue-router' {
|
|||||||
affix?: boolean
|
affix?: boolean
|
||||||
breadcrumb?: boolean
|
breadcrumb?: boolean
|
||||||
activeMenu?: string
|
activeMenu?: string
|
||||||
|
feature?: string // 功能开关标识
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,25 +137,25 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: 'duo-practice',
|
path: 'duo-practice',
|
||||||
name: 'DuoPractice',
|
name: 'DuoPractice',
|
||||||
component: () => import('@/views/trainee/duo-practice.vue'),
|
component: () => import('@/views/trainee/duo-practice.vue'),
|
||||||
meta: { title: '双人对练', icon: 'Connection' }
|
meta: { title: '双人对练', icon: 'Connection', feature: 'duo-practice' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'duo-practice/room/:code',
|
path: 'duo-practice/room/:code',
|
||||||
name: 'DuoPracticeRoom',
|
name: 'DuoPracticeRoom',
|
||||||
component: () => import('@/views/trainee/duo-practice-room.vue'),
|
component: () => import('@/views/trainee/duo-practice-room.vue'),
|
||||||
meta: { title: '对练房间', hidden: true }
|
meta: { title: '对练房间', hidden: true, feature: 'duo-practice' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'duo-practice/join/:code',
|
path: 'duo-practice/join/:code',
|
||||||
name: 'DuoPracticeJoin',
|
name: 'DuoPracticeJoin',
|
||||||
component: () => import('@/views/trainee/duo-practice-room.vue'),
|
component: () => import('@/views/trainee/duo-practice-room.vue'),
|
||||||
meta: { title: '加入对练', hidden: true }
|
meta: { title: '加入对练', hidden: true, feature: 'duo-practice' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'duo-practice/report/:id',
|
path: 'duo-practice/report/:id',
|
||||||
name: 'DuoPracticeReport',
|
name: 'DuoPracticeReport',
|
||||||
component: () => import('@/views/trainee/duo-practice-report.vue'),
|
component: () => import('@/views/trainee/duo-practice-report.vue'),
|
||||||
meta: { title: '对练报告', hidden: true }
|
meta: { title: '对练报告', hidden: true, feature: 'duo-practice' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -92,10 +92,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 其他设置(预留) -->
|
<!-- 员工同步配置 -->
|
||||||
<el-tab-pane label="其他设置" name="other" disabled>
|
<el-tab-pane label="员工同步" name="employee_sync">
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<el-empty description="暂无其他设置项" />
|
<el-alert
|
||||||
|
title="员工同步配置说明"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
style="margin-bottom: 20px;"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<p>通过钉钉开放 API 自动同步组织架构和员工信息(姓名、手机号、部门、岗位等)。</p>
|
||||||
|
<p style="margin-top: 8px;">同步的员工将自动创建系统账号,初始密码为 123456。</p>
|
||||||
|
<p style="margin-top: 8px; color: #E6A23C;">注意:员工同步复用「钉钉免密登录」的 API 凭证配置。</p>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
ref="syncFormRef"
|
||||||
|
:model="syncForm"
|
||||||
|
label-width="140px"
|
||||||
|
v-loading="syncLoading"
|
||||||
|
>
|
||||||
|
<el-form-item label="启用自动同步">
|
||||||
|
<el-switch
|
||||||
|
v-model="syncForm.enabled"
|
||||||
|
active-text="已启用"
|
||||||
|
inactive-text="已禁用"
|
||||||
|
/>
|
||||||
|
<span class="form-tip">启用后将每日自动从钉钉同步员工数据</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="钉钉 API 状态">
|
||||||
|
<el-tag :type="syncForm.configured ? 'success' : 'warning'">
|
||||||
|
{{ syncForm.configured ? '已配置' : '未配置' }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="form-tip" v-if="!syncForm.configured">
|
||||||
|
请先在「钉钉免密登录」页签配置 API 凭证
|
||||||
|
</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="saveSyncConfig" :loading="syncSaving">
|
||||||
|
保存配置
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="testSyncConnection" :loading="syncTesting" :disabled="!syncForm.configured">
|
||||||
|
测试连接
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
@@ -114,6 +160,12 @@ const loading = ref(false)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const dingtalkFormRef = ref<FormInstance>()
|
const dingtalkFormRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
// 员工同步配置
|
||||||
|
const syncLoading = ref(false)
|
||||||
|
const syncSaving = ref(false)
|
||||||
|
const syncTesting = ref(false)
|
||||||
|
const syncFormRef = ref<FormInstance>()
|
||||||
|
|
||||||
// 钉钉配置表单
|
// 钉钉配置表单
|
||||||
const dingtalkForm = reactive({
|
const dingtalkForm = reactive({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -124,6 +176,12 @@ const dingtalkForm = reactive({
|
|||||||
corp_id: '',
|
corp_id: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 员工同步配置表单(复用钉钉免密登录配置)
|
||||||
|
const syncForm = reactive({
|
||||||
|
enabled: false,
|
||||||
|
configured: false, // 钉钉 API 是否已配置
|
||||||
|
})
|
||||||
|
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
const dingtalkRules = reactive<FormRules>({
|
const dingtalkRules = reactive<FormRules>({
|
||||||
app_key: [
|
app_key: [
|
||||||
@@ -137,6 +195,8 @@ const dingtalkRules = reactive<FormRules>({
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const syncRules = reactive<FormRules>({})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载钉钉配置
|
* 加载钉钉配置
|
||||||
*/
|
*/
|
||||||
@@ -206,9 +266,71 @@ const saveDingtalkConfig = async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载员工同步配置(复用钉钉免密登录配置)
|
||||||
|
*/
|
||||||
|
const loadSyncConfig = async () => {
|
||||||
|
syncLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await request.get('/api/v1/settings/employee-sync')
|
||||||
|
if (response.code === 200 && response.data) {
|
||||||
|
syncForm.enabled = response.data.enabled || false
|
||||||
|
syncForm.configured = response.data.configured || false
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('加载员工同步配置失败:', error)
|
||||||
|
} finally {
|
||||||
|
syncLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存员工同步配置(仅开关)
|
||||||
|
*/
|
||||||
|
const saveSyncConfig = async () => {
|
||||||
|
syncSaving.value = true
|
||||||
|
try {
|
||||||
|
const response = await request.put('/api/v1/settings/employee-sync', {
|
||||||
|
enabled: syncForm.enabled,
|
||||||
|
})
|
||||||
|
if (response.code === 200) {
|
||||||
|
ElMessage.success('配置保存成功')
|
||||||
|
await loadSyncConfig()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(response.message || '保存失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('保存员工同步配置失败:', error)
|
||||||
|
ElMessage.error('保存配置失败')
|
||||||
|
} finally {
|
||||||
|
syncSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试员工同步数据库连接
|
||||||
|
*/
|
||||||
|
const testSyncConnection = async () => {
|
||||||
|
syncTesting.value = true
|
||||||
|
try {
|
||||||
|
const response = await request.post('/api/v1/settings/employee-sync/test')
|
||||||
|
if (response.code === 200) {
|
||||||
|
ElMessage.success(response.message || '连接成功')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(response.message || '连接失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('测试连接失败:', error)
|
||||||
|
ElMessage.error('测试连接失败')
|
||||||
|
} finally {
|
||||||
|
syncTesting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 页面加载时获取配置
|
// 页面加载时获取配置
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadDingtalkConfig()
|
loadDingtalkConfig()
|
||||||
|
loadSyncConfig()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -134,12 +134,13 @@
|
|||||||
<el-empty v-if="taskList.length === 0" description="暂无任务" />
|
<el-empty v-if="taskList.length === 0" description="暂无任务" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 创建任务弹窗 -->
|
<!-- 创建/编辑任务弹窗 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="createDialogVisible"
|
v-model="createDialogVisible"
|
||||||
title="创建学习任务"
|
:title="isEditMode ? '编辑学习任务' : '创建学习任务'"
|
||||||
width="680px"
|
width="680px"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
|
@close="resetForm"
|
||||||
>
|
>
|
||||||
<el-form ref="formRef" :model="taskForm" :rules="rules" label-width="100px">
|
<el-form ref="formRef" :model="taskForm" :rules="rules" label-width="100px">
|
||||||
<el-form-item label="任务名称" prop="title">
|
<el-form-item label="任务名称" prop="title">
|
||||||
@@ -165,7 +166,7 @@
|
|||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="分配对象" prop="assignType">
|
<el-form-item v-if="!isEditMode" label="分配对象" prop="assignType">
|
||||||
<el-radio-group v-model="taskForm.assignType" @change="handleAssignTypeChange">
|
<el-radio-group v-model="taskForm.assignType" @change="handleAssignTypeChange">
|
||||||
<el-radio label="all">全体成员</el-radio>
|
<el-radio label="all">全体成员</el-radio>
|
||||||
<el-radio label="team">指定团队</el-radio>
|
<el-radio label="team">指定团队</el-radio>
|
||||||
@@ -173,34 +174,36 @@
|
|||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item v-if="taskForm.assignType === 'team'" label="选择团队" prop="teams">
|
<el-form-item v-if="!isEditMode && taskForm.assignType === 'team'" label="选择团队" prop="teams">
|
||||||
<el-select v-model="taskForm.teams" multiple placeholder="请选择团队" style="width: 100%">
|
<el-select v-model="taskForm.teams" multiple placeholder="请选择团队" style="width: 100%" filterable>
|
||||||
<el-option label="销售一组" value="team1" />
|
<el-option
|
||||||
<el-option label="销售二组" value="team2" />
|
v-for="team in teamOptions"
|
||||||
<el-option label="销售三组" value="team3" />
|
:key="team.id"
|
||||||
|
:label="team.name"
|
||||||
|
:value="team.id"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item v-if="taskForm.assignType === 'member'" label="选择成员" prop="members">
|
<el-form-item v-if="!isEditMode && taskForm.assignType === 'member'" label="选择成员" prop="members">
|
||||||
<el-select v-model="taskForm.members" multiple placeholder="请选择成员" style="width: 100%">
|
<el-select v-model="taskForm.members" multiple placeholder="请选择成员" style="width: 100%" filterable>
|
||||||
<el-option label="张三" value="user1" />
|
<el-option
|
||||||
<el-option label="李四" value="user2" />
|
v-for="member in memberOptions"
|
||||||
<el-option label="王五" value="user3" />
|
:key="member.id"
|
||||||
<el-option label="赵六" value="user4" />
|
:label="member.full_name || member.username"
|
||||||
|
:value="member.id"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="选择课程" prop="courses">
|
<el-form-item v-if="!isEditMode" label="选择课程" prop="courses">
|
||||||
<el-select v-model="taskForm.courses" multiple placeholder="请选择课程" style="width: 100%">
|
<el-select v-model="taskForm.courses" multiple placeholder="请选择课程" style="width: 100%" filterable>
|
||||||
<el-option-group label="销售技巧">
|
<el-option
|
||||||
<el-option label="客户沟通技巧" value="course1" />
|
v-for="course in courseOptions"
|
||||||
<el-option label="需求挖掘方法" value="course2" />
|
:key="course.id"
|
||||||
<el-option label="异议处理技巧" value="course3" />
|
:label="course.name"
|
||||||
</el-option-group>
|
:value="course.id"
|
||||||
<el-option-group label="产品知识">
|
/>
|
||||||
<el-option label="产品基础知识" value="course4" />
|
|
||||||
<el-option label="竞品分析" value="course5" />
|
|
||||||
</el-option-group>
|
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
@@ -228,11 +231,91 @@
|
|||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="createDialogVisible = false">取消</el-button>
|
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" @click="handleCreateTask" :loading="createLoading">
|
<el-button type="primary" @click="handleCreateTask" :loading="createLoading">
|
||||||
确定
|
{{ isEditMode ? '保存' : '确定' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 任务详情弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="detailDialogVisible"
|
||||||
|
title="任务详情"
|
||||||
|
width="600px"
|
||||||
|
>
|
||||||
|
<div v-loading="detailLoading" class="task-detail-content">
|
||||||
|
<template v-if="currentTaskDetail">
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4>基本信息</h4>
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="任务名称" :span="2">
|
||||||
|
{{ currentTaskDetail.title }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="任务描述" :span="2">
|
||||||
|
{{ currentTaskDetail.description || '暂无描述' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="优先级">
|
||||||
|
<el-tag :type="getTaskTagType(currentTaskDetail.priority)" size="small">
|
||||||
|
{{ currentTaskDetail.priority }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<el-tag :type="currentTaskDetail.status === 'completed' ? 'success' : 'warning'" size="small">
|
||||||
|
{{ currentTaskDetail.status === 'completed' ? '已完成' : currentTaskDetail.status === 'ongoing' ? '进行中' : currentTaskDetail.status }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="截止时间">
|
||||||
|
{{ formatDeadline(currentTaskDetail.deadline) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">
|
||||||
|
{{ formatDeadline(currentTaskDetail.created_at) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4>完成进度</h4>
|
||||||
|
<div class="progress-info">
|
||||||
|
<el-progress
|
||||||
|
:percentage="currentTaskDetail.progress"
|
||||||
|
:color="getProgressColor(currentTaskDetail.progress)"
|
||||||
|
:stroke-width="20"
|
||||||
|
/>
|
||||||
|
<p class="progress-text">
|
||||||
|
{{ currentTaskDetail.completed_count }}/{{ currentTaskDetail.assigned_count }} 人完成
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section" v-if="currentTaskDetail.courses && currentTaskDetail.courses.length > 0">
|
||||||
|
<h4>包含课程 ({{ currentTaskDetail.courses.length }}门)</h4>
|
||||||
|
<div class="course-tags">
|
||||||
|
<el-tag v-for="course in currentTaskDetail.courses" :key="course" class="course-tag">
|
||||||
|
{{ course }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section" v-if="currentTaskDetail.requirements">
|
||||||
|
<h4>任务要求</h4>
|
||||||
|
<ul class="requirements-list">
|
||||||
|
<li v-if="currentTaskDetail.requirements.mustComplete">
|
||||||
|
<el-icon><CircleCheck /></el-icon> 必须完成所有课程
|
||||||
|
</li>
|
||||||
|
<li v-if="currentTaskDetail.requirements.mustPass">
|
||||||
|
<el-icon><CircleCheck /></el-icon> 考试必须及格
|
||||||
|
</li>
|
||||||
|
<li v-if="currentTaskDetail.requirements.mustPractice">
|
||||||
|
<el-icon><CircleCheck /></el-icon> 必须完成AI陪练
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="detailDialogVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -240,16 +323,30 @@
|
|||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import { getTasks, getTaskStats, createTask as createTaskApi, deleteTask, type Task } from '@/api/task'
|
import { getTasks, getTaskStats, createTask as createTaskApi, deleteTask, updateTask, getTaskDetail, sendTaskReminder, type Task } from '@/api/task'
|
||||||
|
import { getUserList, getTeamList } from '@/api/user/index'
|
||||||
|
import { getCourseList } from '@/api/score'
|
||||||
|
|
||||||
// 当前标签页
|
// 当前标签页
|
||||||
const activeTab = ref('ongoing')
|
const activeTab = ref('ongoing')
|
||||||
|
|
||||||
// 创建任务弹窗
|
// 创建/编辑任务弹窗
|
||||||
const createDialogVisible = ref(false)
|
const createDialogVisible = ref(false)
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
const createLoading = ref(false)
|
const createLoading = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const isEditMode = ref(false)
|
||||||
|
const editingTaskId = ref<number | null>(null)
|
||||||
|
|
||||||
|
// 详情弹窗
|
||||||
|
const detailDialogVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const currentTaskDetail = ref<Task | null>(null)
|
||||||
|
|
||||||
|
// 选项数据
|
||||||
|
const teamOptions = ref<Array<{ id: number; name: string }>>([])
|
||||||
|
const memberOptions = ref<Array<{ id: number; username: string; full_name?: string }>>([])
|
||||||
|
const courseOptions = ref<Array<{ id: number; name: string; category?: string }>>([])
|
||||||
|
|
||||||
// 任务统计数据
|
// 任务统计数据
|
||||||
const taskStats = ref([
|
const taskStats = ref([
|
||||||
@@ -292,9 +389,9 @@ const taskForm = reactive({
|
|||||||
description: '',
|
description: '',
|
||||||
priority: '中',
|
priority: '中',
|
||||||
assignType: 'all',
|
assignType: 'all',
|
||||||
teams: [],
|
teams: [] as number[],
|
||||||
members: [],
|
members: [] as number[],
|
||||||
courses: [],
|
courses: [] as number[],
|
||||||
deadline: '',
|
deadline: '',
|
||||||
requirements: ['mustComplete']
|
requirements: ['mustComplete']
|
||||||
})
|
})
|
||||||
@@ -382,10 +479,62 @@ const formatDeadline = (deadline?: string) => {
|
|||||||
return `${year}-${month}-${day}`
|
return `${year}-${month}-${day}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载选项数据(团队、成员、课程)
|
||||||
|
*/
|
||||||
|
const loadOptions = async () => {
|
||||||
|
try {
|
||||||
|
// 并行加载
|
||||||
|
const [teamsRes, usersRes, coursesRes] = await Promise.all([
|
||||||
|
getTeamList({ page: 1, page_size: 100 }).catch(() => null),
|
||||||
|
getUserList({ page: 1, page_size: 500 }).catch(() => null),
|
||||||
|
getCourseList().catch(() => null)
|
||||||
|
])
|
||||||
|
|
||||||
|
// 处理团队数据
|
||||||
|
if (teamsRes?.code === 200 && teamsRes.data) {
|
||||||
|
const teamsData = teamsRes.data.items || teamsRes.data
|
||||||
|
teamOptions.value = Array.isArray(teamsData) ? teamsData : []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理成员数据
|
||||||
|
if (usersRes?.code === 200 && usersRes.data) {
|
||||||
|
const usersData = usersRes.data.items || usersRes.data
|
||||||
|
memberOptions.value = Array.isArray(usersData) ? usersData : []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理课程数据
|
||||||
|
if (coursesRes?.code === 200 && coursesRes.data) {
|
||||||
|
const coursesData = coursesRes.data.items || coursesRes.data
|
||||||
|
courseOptions.value = Array.isArray(coursesData) ? coursesData : []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载选项数据失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置表单
|
||||||
|
*/
|
||||||
|
const resetForm = () => {
|
||||||
|
taskForm.title = ''
|
||||||
|
taskForm.description = ''
|
||||||
|
taskForm.priority = '中'
|
||||||
|
taskForm.assignType = 'all'
|
||||||
|
taskForm.teams = []
|
||||||
|
taskForm.members = []
|
||||||
|
taskForm.courses = []
|
||||||
|
taskForm.deadline = ''
|
||||||
|
taskForm.requirements = ['mustComplete']
|
||||||
|
isEditMode.value = false
|
||||||
|
editingTaskId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建任务
|
* 创建任务
|
||||||
*/
|
*/
|
||||||
const createTask = () => {
|
const createTask = () => {
|
||||||
|
resetForm()
|
||||||
createDialogVisible.value = true
|
createDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +547,7 @@ const handleAssignTypeChange = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提交创建任务
|
* 提交创建/编辑任务
|
||||||
*/
|
*/
|
||||||
const handleCreateTask = async () => {
|
const handleCreateTask = async () => {
|
||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
@@ -418,24 +567,38 @@ const handleCreateTask = async () => {
|
|||||||
user_ids: taskForm.assignType === 'all' ? [] : taskForm.members,
|
user_ids: taskForm.assignType === 'all' ? [] : taskForm.members,
|
||||||
requirements: {
|
requirements: {
|
||||||
mustComplete: taskForm.requirements.includes('mustComplete'),
|
mustComplete: taskForm.requirements.includes('mustComplete'),
|
||||||
allowRetake: taskForm.requirements.includes('allowRetake')
|
mustPass: taskForm.requirements.includes('mustPass'),
|
||||||
|
mustPractice: taskForm.requirements.includes('mustPractice')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await createTaskApi(taskData)
|
let res
|
||||||
|
if (isEditMode.value && editingTaskId.value) {
|
||||||
|
// 编辑模式
|
||||||
|
res = await updateTask(editingTaskId.value, {
|
||||||
|
title: taskData.title,
|
||||||
|
description: taskData.description,
|
||||||
|
priority: taskData.priority,
|
||||||
|
deadline: taskData.deadline
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 创建模式
|
||||||
|
res = await createTaskApi(taskData)
|
||||||
|
}
|
||||||
|
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
ElMessage.success('任务创建成功')
|
ElMessage.success(isEditMode.value ? '任务更新成功' : '任务创建成功')
|
||||||
createDialogVisible.value = false
|
createDialogVisible.value = false
|
||||||
formRef.value?.resetFields()
|
resetForm()
|
||||||
// 刷新数据
|
// 刷新数据
|
||||||
await loadTaskStats()
|
await loadTaskStats()
|
||||||
await loadTasks()
|
await loadTasks()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res.message || '创建任务失败')
|
ElMessage.error(res.message || (isEditMode.value ? '更新任务失败' : '创建任务失败'))
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('创建任务失败:', error)
|
console.error(isEditMode.value ? '更新任务失败:' : '创建任务失败:', error)
|
||||||
ElMessage.error(error.message || '创建任务失败')
|
ElMessage.error(error.message || (isEditMode.value ? '更新任务失败' : '创建任务失败'))
|
||||||
} finally {
|
} finally {
|
||||||
createLoading.value = false
|
createLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -446,15 +609,31 @@ const handleCreateTask = async () => {
|
|||||||
/**
|
/**
|
||||||
* 查看详情
|
* 查看详情
|
||||||
*/
|
*/
|
||||||
const viewDetail = (task: any) => {
|
const viewDetail = async (task: Task) => {
|
||||||
ElMessage.info(`查看任务详情:${task.title}`)
|
detailLoading.value = true
|
||||||
|
detailDialogVisible.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getTaskDetail(task.id)
|
||||||
|
if (res.code === 200 && res.data) {
|
||||||
|
currentTaskDetail.value = res.data
|
||||||
|
} else {
|
||||||
|
currentTaskDetail.value = task
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取任务详情失败:', error)
|
||||||
|
currentTaskDetail.value = task
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送提醒
|
* 发送提醒
|
||||||
*/
|
*/
|
||||||
const sendReminder = (_task: any) => {
|
const sendReminder = async (task: Task) => {
|
||||||
ElMessageBox.confirm(
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
`确定要向未完成的成员发送任务提醒吗?`,
|
`确定要向未完成的成员发送任务提醒吗?`,
|
||||||
'发送提醒',
|
'发送提醒',
|
||||||
{
|
{
|
||||||
@@ -462,32 +641,81 @@ const sendReminder = (_task: any) => {
|
|||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'info'
|
type: 'info'
|
||||||
}
|
}
|
||||||
).then(() => {
|
)
|
||||||
ElMessage.success('提醒发送成功')
|
|
||||||
}).catch(() => {})
|
const res = await sendTaskReminder(task.id)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success(res.message || '提醒发送成功')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '发送提醒失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
console.error('发送提醒失败:', error)
|
||||||
|
ElMessage.error(error.message || '发送提醒失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 编辑任务
|
* 编辑任务
|
||||||
*/
|
*/
|
||||||
const editTask = async (task: Task) => {
|
const editTask = async (task: Task) => {
|
||||||
// 这里可以打开编辑对话框,填充task数据
|
isEditMode.value = true
|
||||||
// 简化实现:直接提示
|
editingTaskId.value = task.id
|
||||||
ElMessage.info(`编辑任务功能开发中:${task.title}`)
|
|
||||||
// TODO: 实现完整的编辑功能
|
// 填充表单数据
|
||||||
|
taskForm.title = task.title
|
||||||
|
taskForm.description = task.description || ''
|
||||||
|
taskForm.priority = task.priority === 'high' ? '高' : task.priority === 'low' ? '低' : '中'
|
||||||
|
taskForm.deadline = task.deadline || ''
|
||||||
|
taskForm.assignType = 'all'
|
||||||
|
taskForm.teams = []
|
||||||
|
taskForm.members = []
|
||||||
|
taskForm.courses = []
|
||||||
|
|
||||||
|
// 解析 requirements
|
||||||
|
if (task.requirements) {
|
||||||
|
taskForm.requirements = []
|
||||||
|
if (task.requirements.mustComplete) taskForm.requirements.push('mustComplete')
|
||||||
|
if (task.requirements.mustPass) taskForm.requirements.push('mustPass')
|
||||||
|
if (task.requirements.mustPractice) taskForm.requirements.push('mustPractice')
|
||||||
|
}
|
||||||
|
|
||||||
|
createDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 复制任务
|
* 复制任务
|
||||||
*/
|
*/
|
||||||
const copyTask = (task: any) => {
|
const copyTask = async (task: Task) => {
|
||||||
ElMessage.success(`已复制任务:${task.title}`)
|
resetForm()
|
||||||
|
|
||||||
|
// 填充表单数据(标题添加"副本"后缀)
|
||||||
|
taskForm.title = `${task.title} (副本)`
|
||||||
|
taskForm.description = task.description || ''
|
||||||
|
taskForm.priority = task.priority === 'high' ? '高' : task.priority === 'low' ? '低' : '中'
|
||||||
|
taskForm.deadline = '' // 截止时间需要重新设置
|
||||||
|
|
||||||
|
// 解析 requirements
|
||||||
|
if (task.requirements) {
|
||||||
|
taskForm.requirements = []
|
||||||
|
if (task.requirements.mustComplete) taskForm.requirements.push('mustComplete')
|
||||||
|
if (task.requirements.mustPass) taskForm.requirements.push('mustPass')
|
||||||
|
if (task.requirements.mustPractice) taskForm.requirements.push('mustPractice')
|
||||||
|
}
|
||||||
|
|
||||||
|
isEditMode.value = false
|
||||||
|
editingTaskId.value = null
|
||||||
|
createDialogVisible.value = true
|
||||||
|
|
||||||
|
ElMessage.info('已复制任务内容,请修改后保存')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 结束任务
|
* 结束任务
|
||||||
*/
|
*/
|
||||||
const endTask = async (_task: Task) => {
|
const endTask = async (task: Task) => {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
'确定要结束这个任务吗?结束后将不能再修改。',
|
'确定要结束这个任务吗?结束后将不能再修改。',
|
||||||
@@ -498,8 +726,22 @@ const endTask = async (_task: Task) => {
|
|||||||
type: 'warning'
|
type: 'warning'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const res = await updateTask(task.id, { status: 'completed' })
|
||||||
|
if (res.code === 200) {
|
||||||
ElMessage.success('任务已结束')
|
ElMessage.success('任务已结束')
|
||||||
} catch {}
|
// 刷新数据
|
||||||
|
await loadTaskStats()
|
||||||
|
await loadTasks()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '结束任务失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
console.error('结束任务失败:', error)
|
||||||
|
ElMessage.error(error.message || '结束任务失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -558,8 +800,11 @@ const getProgressColor = (percentage: number) => {
|
|||||||
|
|
||||||
// 组件挂载时加载数据
|
// 组件挂载时加载数据
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadTaskStats()
|
await Promise.all([
|
||||||
await loadTasks()
|
loadTaskStats(),
|
||||||
|
loadTasks(),
|
||||||
|
loadOptions()
|
||||||
|
])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -744,6 +989,60 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 任务详情弹窗样式
|
||||||
|
.task-detail-content {
|
||||||
|
.detail-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-info {
|
||||||
|
.progress-text {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.course-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.requirements-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 响应式
|
// 响应式
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.assignment-center-container {
|
.assignment-center-container {
|
||||||
|
|||||||
@@ -611,7 +611,7 @@
|
|||||||
:on-remove="handleFileRemove"
|
:on-remove="handleFileRemove"
|
||||||
:before-upload="beforeUpload"
|
:before-upload="beforeUpload"
|
||||||
multiple
|
multiple
|
||||||
accept=".txt,.md,.mdx,.pdf,.html,.htm,.xlsx,.xls,.docx,.csv,.vtt,.properties"
|
accept=".txt,.md,.mdx,.pdf,.html,.htm,.xlsx,.xls,.docx,.doc,.pptx,.ppt,.csv,.vtt,.properties"
|
||||||
>
|
>
|
||||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||||
<div class="el-upload__text">
|
<div class="el-upload__text">
|
||||||
@@ -619,7 +619,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<template #tip>
|
<template #tip>
|
||||||
<div class="el-upload__tip">
|
<div class="el-upload__tip">
|
||||||
支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties<br>
|
支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、PPT、CSV、VTT、Properties<br>
|
||||||
单个文件不超过 15MB
|
单个文件不超过 15MB
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user