mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
refactor: v2
backend & frontend
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
# 接龙自动打卡系统 - 后端 API
|
||||
|
||||
FastAPI 后端服务,提供用户管理、QQ 扫码登录、自动打卡等功能。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. 配置环境
|
||||
|
||||
创建 `.env` 文件(可选):
|
||||
|
||||
```env
|
||||
# 邮件通知配置(可选)
|
||||
SMTP_SERVER=smtp.example.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SENDER_EMAIL=your-email@example.com
|
||||
SMTP_SENDER_PASSWORD=your-password-here
|
||||
|
||||
# Chrome 浏览器配置(可选)
|
||||
CHROME_BINARY_PATH=
|
||||
CHROMEDRIVER_PATH=
|
||||
```
|
||||
|
||||
### 3. 初始化数据库
|
||||
|
||||
数据库会在首次启动时自动初始化。
|
||||
|
||||
### 4. 创建管理员用户
|
||||
|
||||
```bash
|
||||
python backend/scripts/create_admin.py
|
||||
```
|
||||
|
||||
按照提示输入管理员信息:
|
||||
- Signature: 管理员标识(唯一)
|
||||
- ThreadId: 接龙 ID
|
||||
- 邮箱: 接收通知的邮箱
|
||||
|
||||
### 5. 启动服务
|
||||
|
||||
```bash
|
||||
# 开发模式(支持热重载)
|
||||
cd backend
|
||||
python main.py
|
||||
|
||||
# 或者使用 uvicorn
|
||||
uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# 生产模式
|
||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
```
|
||||
|
||||
### 6. 访问 API 文档
|
||||
|
||||
启动后访问: http://localhost:8000/docs
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── main.py # FastAPI 应用入口
|
||||
├── config.py # 配置管理
|
||||
├── dependencies.py # 认证中间件
|
||||
├── requirements.txt # Python 依赖
|
||||
├── models/ # 数据库模型
|
||||
│ ├── database.py # 数据库配置
|
||||
│ ├── user.py # User 模型
|
||||
│ └── check_in_record.py # CheckInRecord 模型
|
||||
├── schemas/ # Pydantic Schema
|
||||
│ ├── user.py # 用户相关 Schema
|
||||
│ ├── auth.py # 认证相关 Schema
|
||||
│ └── check_in.py # 打卡相关 Schema
|
||||
├── api/ # API 路由
|
||||
│ ├── auth.py # 认证 API
|
||||
│ ├── users.py # 用户管理 API
|
||||
│ ├── check_in.py # 打卡 API
|
||||
│ └── admin.py # 管理员 API
|
||||
├── services/ # 业务逻辑层
|
||||
│ ├── auth_service.py # 认证服务
|
||||
│ ├── user_service.py # 用户服务
|
||||
│ ├── check_in_service.py # 打卡服务
|
||||
│ └── scheduler_service.py # 调度服务
|
||||
├── workers/ # Selenium 工作模块
|
||||
│ ├── token_refresher.py # Token 刷新(QQ 扫码)
|
||||
│ ├── check_in_worker.py # 打卡执行
|
||||
│ └── email_notifier.py # 邮件通知
|
||||
└── scripts/ # 工具脚本
|
||||
└── create_admin.py # 创建管理员用户
|
||||
```
|
||||
|
||||
## 🔌 API 端点
|
||||
|
||||
### 认证 API (`/api/auth`)
|
||||
|
||||
- `POST /api/auth/request_qrcode` - 请求 QQ 扫码二维码
|
||||
- `GET /api/auth/qrcode_status/{session_id}` - 检查扫码状态
|
||||
- `POST /api/auth/verify_token` - 验证 Token 有效性
|
||||
|
||||
### 用户 API (`/api/users`)
|
||||
|
||||
- `POST /api/users` - 创建用户(管理员)
|
||||
- `GET /api/users/me` - 获取当前用户信息
|
||||
- `GET /api/users/me/token_status` - 获取 Token 状态
|
||||
- `GET /api/users` - 获取所有用户(管理员)
|
||||
- `GET /api/users/{user_id}` - 获取指定用户
|
||||
- `PUT /api/users/{user_id}` - 更新用户信息
|
||||
- `DELETE /api/users/{user_id}` - 删除用户(管理员)
|
||||
|
||||
### 打卡 API (`/api/check_in`)
|
||||
|
||||
- `POST /api/check_in/manual` - 手动触发打卡
|
||||
- `GET /api/check_in/my_records` - 查看自己的打卡记录
|
||||
- `GET /api/check_in/records` - 查看所有打卡记录(管理员)
|
||||
- `GET /api/check_in/records/count` - 获取打卡记录统计(管理员)
|
||||
|
||||
### 管理员 API (`/api/admin`)
|
||||
|
||||
- `POST /api/admin/batch_toggle_active` - 批量启用/禁用用户
|
||||
- `POST /api/admin/batch_check_in` - 批量触发打卡
|
||||
- `GET /api/admin/logs` - 获取系统日志
|
||||
- `GET /api/admin/stats` - 获取系统统计
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
### 邮件配置 (`config.ini`)
|
||||
|
||||
在项目根目录创建 `config.ini`:
|
||||
|
||||
```ini
|
||||
[Email]
|
||||
smtpserver = smtp.example.com
|
||||
smtpport = 465
|
||||
senderemail = your-email@example.com
|
||||
senderpassword = your-password
|
||||
```
|
||||
|
||||
### 定时任务配置
|
||||
|
||||
在 `backend/config.py` 中配置:
|
||||
|
||||
- `CHECKIN_SCHEDULE_HOUR`: 定时打卡小时(默认 20)
|
||||
- `CHECKIN_SCHEDULE_MINUTE`: 定时打卡分钟(默认 0)
|
||||
- `TOKEN_CHECK_INTERVAL_MINUTES`: Token 检查间隔(默认 30 分钟)
|
||||
- `SESSION_CLEANUP_INTERVAL_HOURS`: 会话清理间隔(默认 24 小时)
|
||||
|
||||
## 🔐 认证流程
|
||||
|
||||
1. 用户输入 Signature 并请求二维码
|
||||
2. 后端启动 Selenium 获取 QQ 登录二维码
|
||||
3. 前端轮询检查扫码状态
|
||||
4. 用户使用手机 QQ 扫码
|
||||
5. 后端获取 Token 并解析 JWT
|
||||
6. 用户后续请求使用 `Authorization: Bearer <token>` header
|
||||
|
||||
## 📊 定时任务
|
||||
|
||||
系统会自动执行以下定时任务:
|
||||
|
||||
1. **定时打卡**: 每天 20:00 为所有启用的用户执行打卡
|
||||
2. **Token 过期检查**: 每 30 分钟检查一次,Token 在 30 分钟内过期时发送邮件提醒
|
||||
3. **会话文件清理**: 每 24 小时清理超过 24 小时的旧会话文件
|
||||
|
||||
## 🛠️ 开发说明
|
||||
|
||||
### 添加新的 API 端点
|
||||
|
||||
1. 在 `backend/schemas/` 中定义请求/响应 Schema
|
||||
2. 在 `backend/services/` 中实现业务逻辑
|
||||
3. 在 `backend/api/` 中创建 API 路由
|
||||
4. 在 `backend/main.py` 中注册路由
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
如果修改了模型,删除 `data/checkin.db` 并重启服务即可重新创建数据库。
|
||||
|
||||
⚠️ 注意:生产环境建议使用 Alembic 进行数据库迁移。
|
||||
|
||||
## 🐛 故障排查
|
||||
|
||||
### 问题:无法启动 Selenium
|
||||
|
||||
确保已安装 Chrome 和 ChromeDriver:
|
||||
|
||||
```bash
|
||||
# 检查路径配置
|
||||
ls chrome-linux64/chrome
|
||||
ls chromedriver
|
||||
```
|
||||
|
||||
### 问题:Token 验证失败
|
||||
|
||||
检查数据库中用户的 `authorization` 字段是否有值。
|
||||
|
||||
### 问题:定时任务未执行
|
||||
|
||||
检查日志文件 `logs/CheckIn.log`,确认调度器是否成功启动。
|
||||
|
||||
### 问题:邮件发送失败
|
||||
|
||||
检查 `config.ini` 配置是否正确,SMTP 服务器是否可访问。
|
||||
|
||||
## 📝 环境变量
|
||||
|
||||
可选的环境变量:
|
||||
|
||||
- `DATABASE_URL`: 数据库 URL(默认使用 SQLite)
|
||||
- `CORS_ORIGINS`: 允许的前端域名(默认 localhost:5173 和 localhost:3000)
|
||||
- `SMTP_SERVER`: 邮件服务器地址(用于邮件通知,可选)
|
||||
- `SMTP_SENDER_EMAIL`: 发件人邮箱(用于邮件通知,可选)
|
||||
- `CHROME_BINARY_PATH`: Chrome 浏览器路径(可选,留空自动检测)
|
||||
- `CHROMEDRIVER_PATH`: ChromeDriver 路径(可选,留空自动下载)
|
||||
|
||||
## 🚀 部署建议
|
||||
|
||||
### 使用 Gunicorn
|
||||
|
||||
```bash
|
||||
pip install gunicorn
|
||||
gunicorn backend.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000
|
||||
```
|
||||
|
||||
### 使用 Systemd
|
||||
|
||||
创建 `/etc/systemd/system/checkin-api.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=CheckIn API Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=your-user
|
||||
WorkingDirectory=/path/to/CheckInApp
|
||||
Environment="PATH=/path/to/venv/bin"
|
||||
ExecStart=/path/to/venv/bin/gunicorn backend.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
启动服务:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable checkin-api
|
||||
sudo systemctl start checkin-api
|
||||
sudo systemctl status checkin-api
|
||||
```
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目仅供学习和研究使用。
|
||||
@@ -0,0 +1,309 @@
|
||||
from typing import List
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.models import get_db, User, CheckInTask
|
||||
from backend.schemas.check_in import BatchCheckInRequest
|
||||
from backend.schemas.user import UserResponse
|
||||
from backend.services.check_in_service import CheckInService
|
||||
from backend.services.admin_service import AdminService
|
||||
from backend.dependencies import get_current_admin_user
|
||||
from backend.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class BatchToggleTasksRequest(BaseModel):
|
||||
"""批量启用/禁用任务请求"""
|
||||
task_ids: List[int]
|
||||
is_active: bool
|
||||
|
||||
|
||||
@router.post("/batch_toggle_tasks", summary="批量启用/禁用任务")
|
||||
async def batch_toggle_tasks(
|
||||
request: BatchToggleTasksRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
批量启用或禁用任务的自动打卡功能(需要管理员权限)
|
||||
|
||||
- **task_ids**: 任务 ID 列表
|
||||
- **is_active**: true 为启用,false 为禁用
|
||||
"""
|
||||
try:
|
||||
count = 0
|
||||
for task_id in request.task_ids:
|
||||
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
|
||||
if task:
|
||||
task.is_active = request.is_active
|
||||
count += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"已{'启用' if request.is_active else '禁用'} {count} 个任务",
|
||||
"count": count
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"批量操作失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch_check_in", summary="批量触发打卡")
|
||||
async def batch_check_in(
|
||||
request: BatchCheckInRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
批量触发任务打卡(需要管理员权限)
|
||||
|
||||
- **task_ids**: 任务 ID 列表
|
||||
|
||||
返回每个任务的打卡结果
|
||||
"""
|
||||
try:
|
||||
result = CheckInService.batch_check_in_tasks(request.task_ids, db)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"批量打卡失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logs", summary="获取系统日志")
|
||||
async def get_system_logs(
|
||||
lines: int = Query(200, ge=1, le=2000, description="读取的日志行数"),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
获取系统日志(需要管理员权限)
|
||||
|
||||
- **lines**: 读取最后 N 行日志
|
||||
|
||||
返回日志内容(字符串格式)
|
||||
"""
|
||||
try:
|
||||
log_file = settings.LOG_FILE
|
||||
|
||||
if not log_file.exists():
|
||||
return {
|
||||
"success": True,
|
||||
"message": "日志文件不存在",
|
||||
"logs": "日志文件不存在"
|
||||
}
|
||||
|
||||
# 使用 deque 高效读取最后 N 行,避免将整个文件加载到内存
|
||||
from collections import deque
|
||||
|
||||
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
# 使用 deque 保持最后 N 行,内存占用固定
|
||||
last_lines = deque(f, maxlen=lines)
|
||||
|
||||
# 返回字符串格式(不是数组)
|
||||
log_content = ''.join(last_lines)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"读取了最后 {len(last_lines)} 行日志",
|
||||
"logs": log_content
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"读取日志失败: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"读取日志失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", summary="获取系统统计")
|
||||
async def get_system_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
获取系统统计信息(需要管理员权限)
|
||||
|
||||
返回用户数、任务数、打卡记录数等统计信息
|
||||
"""
|
||||
try:
|
||||
from backend.models import CheckInRecord
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 总用户数
|
||||
total_users = db.query(User).count()
|
||||
|
||||
# 管理员用户数
|
||||
admin_users = db.query(User).filter(User.role == "admin").count()
|
||||
|
||||
# 已审批的用户数(is_approved=True的用户)
|
||||
approved_users = db.query(User).filter(User.is_approved == True).count()
|
||||
|
||||
# 总任务数
|
||||
total_tasks = db.query(CheckInTask).count()
|
||||
|
||||
# 启用的任务数
|
||||
active_tasks = db.query(CheckInTask).filter(CheckInTask.is_active == True).count()
|
||||
|
||||
# 总打卡记录数
|
||||
total_records = db.query(CheckInRecord).count()
|
||||
|
||||
# 今日打卡记录数
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_records = db.query(CheckInRecord).filter(
|
||||
CheckInRecord.check_in_time >= today_start
|
||||
).count()
|
||||
|
||||
# 今日成功打卡数
|
||||
today_success = db.query(CheckInRecord).filter(
|
||||
CheckInRecord.check_in_time >= today_start,
|
||||
CheckInRecord.status == "success"
|
||||
).count()
|
||||
|
||||
# 今日失败打卡数
|
||||
today_failure = db.query(CheckInRecord).filter(
|
||||
CheckInRecord.check_in_time >= today_start,
|
||||
CheckInRecord.status == "failure"
|
||||
).count()
|
||||
|
||||
# 今日时间范围外打卡数
|
||||
today_out_of_time = db.query(CheckInRecord).filter(
|
||||
CheckInRecord.check_in_time >= today_start,
|
||||
CheckInRecord.status == "out_of_time"
|
||||
).count()
|
||||
|
||||
# 今日异常打卡数
|
||||
today_unknown = db.query(CheckInRecord).filter(
|
||||
CheckInRecord.check_in_time >= today_start,
|
||||
CheckInRecord.status == "unknown"
|
||||
).count()
|
||||
|
||||
# Token 即将过期的用户数(7天内)
|
||||
current_timestamp = int(datetime.now().timestamp())
|
||||
expiring_soon_timestamp = current_timestamp + (7 * 24 * 60 * 60) # 7天后
|
||||
|
||||
expiring_users = 0
|
||||
for user in db.query(User).all():
|
||||
if user.jwt_exp and user.jwt_exp != "0":
|
||||
try:
|
||||
exp_timestamp = int(user.jwt_exp)
|
||||
if current_timestamp < exp_timestamp < expiring_soon_timestamp:
|
||||
expiring_users += 1
|
||||
except ValueError:
|
||||
# jwt_exp 格式不正确,跳过此用户
|
||||
logger.debug(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}")
|
||||
continue
|
||||
|
||||
return {
|
||||
"users": {
|
||||
"total": total_users,
|
||||
"admin": admin_users,
|
||||
"regular": total_users - admin_users,
|
||||
"active": approved_users # 使用已审批用户数
|
||||
},
|
||||
"tasks": {
|
||||
"total": total_tasks,
|
||||
"active": active_tasks,
|
||||
"inactive": total_tasks - active_tasks
|
||||
},
|
||||
"check_in_records": {
|
||||
"total": total_records,
|
||||
"today": today_records,
|
||||
"today_success": today_success,
|
||||
"today_failure": today_failure,
|
||||
"today_out_of_time": today_out_of_time,
|
||||
"today_unknown": today_unknown
|
||||
},
|
||||
"tokens": {
|
||||
"expiring_soon": expiring_users
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取统计失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users/pending", response_model=List[UserResponse], summary="获取待审批用户")
|
||||
async def get_pending_users(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
获取所有待审批的用户(需要管理员权限)
|
||||
"""
|
||||
try:
|
||||
users = AdminService.get_pending_users(db)
|
||||
return users
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取待审批用户失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/approve", response_model=dict, summary="审批通过用户")
|
||||
async def approve_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
审批通过指定用户(需要管理员权限)
|
||||
"""
|
||||
try:
|
||||
result = AdminService.approve_user(user_id, db)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result["message"]
|
||||
)
|
||||
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"审批用户失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}/reject", response_model=dict, summary="拒绝用户")
|
||||
async def reject_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
拒绝并删除指定用户(需要管理员权限)
|
||||
"""
|
||||
try:
|
||||
result = AdminService.reject_user(user_id, db)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result["message"]
|
||||
)
|
||||
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"拒绝用户失败: {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import get_db
|
||||
from backend.schemas.auth import (
|
||||
QRCodeRequest,
|
||||
QRCodeResponse,
|
||||
QRCodeStatusResponse,
|
||||
TokenVerifyRequest,
|
||||
TokenVerifyResponse,
|
||||
AliasLoginRequest,
|
||||
AliasLoginResponse,
|
||||
)
|
||||
from backend.services.auth_service import AuthService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/request_qrcode", response_model=dict, summary="请求 QQ 扫码二维码")
|
||||
async def request_qrcode(
|
||||
request_obj: QRCodeRequest,
|
||||
req: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
请求 QQ 扫码二维码
|
||||
|
||||
- **alias**: 用户别名
|
||||
|
||||
返回会话 ID,用于后续查询扫码状态
|
||||
"""
|
||||
from backend.services.registration_manager import registration_manager
|
||||
import secrets
|
||||
|
||||
# 检查注册限流 Cookie
|
||||
reg_cookie = req.cookies.get("reg_limit")
|
||||
|
||||
if reg_cookie:
|
||||
if not registration_manager.check_registration_cookie(reg_cookie):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="注册过于频繁,请 10 分钟后再试"
|
||||
)
|
||||
else:
|
||||
# 生成新的 Cookie
|
||||
reg_cookie = secrets.token_urlsafe(16)
|
||||
|
||||
# 获取客户端 IP
|
||||
client_ip = req.client.host if req.client else "unknown"
|
||||
|
||||
# 如果有代理,尝试从 X-Forwarded-For 获取真实 IP
|
||||
forwarded_for = req.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
client_ip = forwarded_for.split(",")[0].strip()
|
||||
|
||||
try:
|
||||
result = AuthService.request_qrcode(request_obj.alias, client_ip, db)
|
||||
|
||||
# 设置限流 Cookie(10 分钟)
|
||||
response.set_cookie(
|
||||
key="reg_limit",
|
||||
value=reg_cookie,
|
||||
max_age=600, # 10 分钟
|
||||
httponly=True,
|
||||
samesite="lax"
|
||||
)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"创建扫码会话失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/qrcode_status/{session_id}", response_model=dict, summary="检查二维码扫描状态")
|
||||
async def get_qrcode_status(
|
||||
session_id: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
检查二维码扫描状态
|
||||
|
||||
- **session_id**: 会话 ID
|
||||
|
||||
状态说明:
|
||||
- pending: 正在初始化
|
||||
- waiting_scan: 等待扫描(包含二维码图片 Base64)
|
||||
- success: 扫描成功(包含 user_id 和 authorization)
|
||||
- error: 发生错误
|
||||
"""
|
||||
try:
|
||||
result = AuthService.get_qrcode_status(session_id, db)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"查询扫码状态失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify_token", response_model=dict, summary="验证 Token 有效性")
|
||||
async def verify_token(
|
||||
request: TokenVerifyRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
验证 Token 有效性
|
||||
|
||||
- **authorization**: Token(可带或不带 "Bearer " 前缀)
|
||||
|
||||
返回 Token 是否有效以及相关信息
|
||||
"""
|
||||
try:
|
||||
result = AuthService.verify_token(request.authorization, db)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"验证 Token 失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/alias_login", response_model=dict, summary="别名+密码登录")
|
||||
async def alias_login(
|
||||
request: AliasLoginRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
别名+密码登录(仅限已设置密码的用户)
|
||||
|
||||
- **alias**: 用户别名
|
||||
- **password**: 密码
|
||||
|
||||
返回登录结果,成功时包含 user_id 和 authorization
|
||||
|
||||
注意:
|
||||
- 用户必须已设置密码才能使用此方式登录
|
||||
- Token 必须仍然有效(未过期)
|
||||
- 如果 Token 已过期,请使用扫码登录重新获取
|
||||
"""
|
||||
try:
|
||||
result = AuthService.alias_login(request.alias, request.password, db)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"别名登录失败: {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,221 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import get_db, User, CheckInTask, CheckInRecord
|
||||
from backend.schemas.check_in import (
|
||||
ManualCheckInRequest,
|
||||
CheckInRecordResponse,
|
||||
CheckInResultResponse,
|
||||
)
|
||||
from backend.services.check_in_service import CheckInService
|
||||
from backend.services.task_service import TaskService
|
||||
from backend.dependencies import get_current_user, get_current_admin_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/manual/{task_id}", summary="手动触发打卡(异步)")
|
||||
async def manual_check_in(
|
||||
task_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
手动触发指定任务的打卡(异步方式,立即返回)
|
||||
|
||||
- **task_id**: 任务 ID
|
||||
|
||||
返回打卡记录 ID,可以通过 /record/{record_id}/status 查询打卡状态
|
||||
"""
|
||||
# 验证任务归属
|
||||
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问此任务"
|
||||
)
|
||||
|
||||
task = TaskService.get_task(task_id, db)
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="任务不存在"
|
||||
)
|
||||
|
||||
try:
|
||||
result = CheckInService.start_async_check_in(task, "manual", db)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"启动打卡任务失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/record/{record_id}/status", summary="查询打卡记录状态")
|
||||
async def get_check_in_record_status(
|
||||
record_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
查询指定打卡记录的状态
|
||||
|
||||
- **record_id**: 打卡记录 ID
|
||||
|
||||
返回状态:pending(进行中)、success(成功)、failure(失败)
|
||||
"""
|
||||
# 获取打卡记录
|
||||
record = db.query(CheckInRecord).filter(CheckInRecord.id == record_id).first()
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="打卡记录不存在"
|
||||
)
|
||||
|
||||
# 验证记录归属(通过任务归属)
|
||||
if not TaskService.verify_task_ownership(record.task_id, current_user.id, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问此记录"
|
||||
)
|
||||
|
||||
return {
|
||||
"record_id": record.id,
|
||||
"task_id": record.task_id,
|
||||
"status": record.status,
|
||||
"response_text": record.response_text,
|
||||
"error_message": record.error_message,
|
||||
"trigger_type": record.trigger_type,
|
||||
"check_in_time": record.check_in_time
|
||||
}
|
||||
|
||||
|
||||
@router.get("/task/{task_id}/records", response_model=List[CheckInRecordResponse], summary="查看任务的打卡记录")
|
||||
async def get_task_check_in_records(
|
||||
task_id: int,
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
|
||||
status_filter: Optional[str] = Query(None, alias="status", description="过滤状态 (success/failure)"),
|
||||
trigger_type: Optional[str] = Query(None, description="过滤触发类型 (scheduler/manual)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
查看指定任务的打卡记录
|
||||
|
||||
- **task_id**: 任务 ID
|
||||
- **skip**: 跳过记录数
|
||||
- **limit**: 限制记录数
|
||||
- **status**: 过滤状态 (success/failure)
|
||||
- **trigger_type**: 过滤触发类型 (scheduler/manual)
|
||||
|
||||
用户只能查看自己的任务记录
|
||||
"""
|
||||
# 验证任务归属
|
||||
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问此任务"
|
||||
)
|
||||
|
||||
try:
|
||||
records = CheckInService.get_task_records(
|
||||
task_id, db, skip, limit, status_filter, trigger_type
|
||||
)
|
||||
return records
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取打卡记录失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/my-records", response_model=List[CheckInRecordResponse], summary="查看当前用户的所有打卡记录")
|
||||
async def get_my_check_in_records(
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
|
||||
status_filter: Optional[str] = Query(None, alias="status", description="过滤状态 (success/failure)"),
|
||||
trigger_type: Optional[str] = Query(None, description="过滤触发类型 (scheduler/manual)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
查看当前用户所有任务的打卡记录
|
||||
|
||||
- **skip**: 跳过记录数
|
||||
- **limit**: 限制记录数
|
||||
- **status**: 过滤状态 (success/failure)
|
||||
- **trigger_type**: 过滤触发类型 (scheduler/manual)
|
||||
"""
|
||||
try:
|
||||
records = CheckInService.get_user_records(
|
||||
current_user.id, db, skip, limit, status_filter, trigger_type
|
||||
)
|
||||
return records
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取打卡记录失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get("/records", response_model=List[CheckInRecordResponse], summary="查看所有打卡记录(管理员)")
|
||||
async def get_all_check_in_records(
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
|
||||
task_id: Optional[int] = Query(None, description="过滤任务 ID"),
|
||||
status_filter: Optional[str] = Query(None, alias="status", description="过滤状态 (success/failure)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
查看所有打卡记录(需要管理员权限)
|
||||
|
||||
- **skip**: 跳过记录数
|
||||
- **limit**: 限制记录数
|
||||
- **task_id**: 过滤指定任务的记录
|
||||
- **status**: 过滤指定状态的记录
|
||||
"""
|
||||
try:
|
||||
records = CheckInService.get_all_records(db, skip, limit, task_id, status_filter)
|
||||
# 为每条记录添加用户和任务信息
|
||||
enriched_records = [CheckInService.enrich_record_with_user_task_info(record, db) for record in records]
|
||||
return enriched_records
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取打卡记录失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/records/count", summary="获取打卡记录统计(管理员)")
|
||||
async def get_check_in_records_count(
|
||||
task_id: Optional[int] = Query(None, description="过滤任务 ID"),
|
||||
status_filter: Optional[str] = Query(None, alias="status", description="过滤状态 (success/failure)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
获取打卡记录统计(需要管理员权限)
|
||||
|
||||
返回符合条件的记录总数
|
||||
"""
|
||||
try:
|
||||
query = db.query(CheckInRecord)
|
||||
|
||||
if task_id:
|
||||
query = query.filter(CheckInRecord.task_id == task_id)
|
||||
|
||||
if status_filter:
|
||||
query = query.filter(CheckInRecord.status == status_filter)
|
||||
|
||||
total = query.count()
|
||||
|
||||
return {"total": total}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取统计失败: {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,251 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from backend.models import get_db, User
|
||||
from backend.schemas.task import TaskCreate, TaskUpdate, TaskResponse
|
||||
from backend.services.task_service import TaskService
|
||||
from backend.dependencies import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, summary="创建打卡任务")
|
||||
async def create_task(
|
||||
task_data: TaskCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
创建新的打卡任务(基于模板)
|
||||
|
||||
现在的任务创建流程:
|
||||
1. 管理员在后台创建模板(包含完整的 payload_config)
|
||||
2. 用户基于模板创建任务,填写字段值
|
||||
3. 系统自动生成完整的 payload_config
|
||||
|
||||
注意:直接创建任务的方式已废弃,请使用模板接口。
|
||||
"""
|
||||
try:
|
||||
task = TaskService.create_task(current_user.id, task_data, db)
|
||||
return task
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"创建任务失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TaskResponse], summary="获取当前用户的任务列表")
|
||||
async def get_tasks(
|
||||
include_inactive: bool = True,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取当前用户的所有打卡任务
|
||||
|
||||
- **include_inactive**: 是否包含未启用的任务(默认 true)
|
||||
"""
|
||||
try:
|
||||
tasks = TaskService.get_user_tasks(current_user.id, db, include_inactive)
|
||||
# 为每个任务添加额外信息
|
||||
enriched_tasks = [TaskService.enrich_task_with_check_in_info(task, db) for task in tasks]
|
||||
return enriched_tasks
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取任务列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskResponse, summary="获取任务详情")
|
||||
async def get_task(
|
||||
task_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取指定任务的详情
|
||||
|
||||
需要验证任务属于当前用户
|
||||
"""
|
||||
# 验证任务归属
|
||||
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问此任务"
|
||||
)
|
||||
|
||||
task = TaskService.get_task(task_id, db)
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="任务不存在"
|
||||
)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@router.put("/{task_id}", response_model=TaskResponse, summary="更新任务")
|
||||
async def update_task(
|
||||
task_id: int,
|
||||
task_data: TaskUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
更新指定任务的信息
|
||||
|
||||
需要验证任务属于当前用户
|
||||
"""
|
||||
# 验证任务归属
|
||||
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问此任务"
|
||||
)
|
||||
|
||||
task = TaskService.update_task(task_id, task_data, db)
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="任务不存在"
|
||||
)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT, summary="删除任务")
|
||||
async def delete_task(
|
||||
task_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
删除指定任务
|
||||
|
||||
需要验证任务属于当前用户,删除后会同时删除所有关联的打卡记录
|
||||
"""
|
||||
# 验证任务归属
|
||||
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问此任务"
|
||||
)
|
||||
|
||||
success = TaskService.delete_task(task_id, db)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="任务不存在"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{task_id}/toggle", response_model=TaskResponse, summary="切换任务启用状态")
|
||||
async def toggle_task(
|
||||
task_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
切换任务的启用/禁用状态
|
||||
|
||||
需要验证任务属于当前用户
|
||||
"""
|
||||
# 验证任务归属
|
||||
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问此任务"
|
||||
)
|
||||
|
||||
task = TaskService.toggle_task(task_id, db)
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="任务不存在"
|
||||
)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/validate-cron", summary="验证 Crontab 表达式")
|
||||
async def validate_cron_expression(request: dict):
|
||||
"""
|
||||
验证 Crontab 表达式并预览下一个执行时间
|
||||
|
||||
请求体: {"cron_expression": "0 20 * * *"}
|
||||
|
||||
返回:
|
||||
{
|
||||
"valid": true,
|
||||
"message": "有效的 Crontab 表达式",
|
||||
"next_times": [
|
||||
"2024-01-02 20:00:00",
|
||||
"2024-01-03 20:00:00",
|
||||
...
|
||||
],
|
||||
"description": "每天 20:00"
|
||||
}
|
||||
"""
|
||||
cron_expr = request.get('cron_expression', '').strip()
|
||||
|
||||
if not cron_expr:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="cron_expression 是必需的"
|
||||
)
|
||||
|
||||
try:
|
||||
from croniter import croniter
|
||||
|
||||
if not croniter.is_valid(cron_expr):
|
||||
raise ValueError("无效的格式")
|
||||
|
||||
# 生成接下来的 5 个执行时间
|
||||
cron = croniter(cron_expr, datetime.now())
|
||||
next_times = [cron.get_next(datetime).strftime('%Y-%m-%d %H:%M:%S') for _ in range(5)]
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"message": "有效的 Crontab 表达式",
|
||||
"next_times": next_times,
|
||||
"description": generate_cron_description(cron_expr)
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效的 Crontab 表达式: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def generate_cron_description(cron_expr: str) -> str:
|
||||
"""生成 Crontab 表达式的人类可读描述"""
|
||||
parts = cron_expr.split()
|
||||
if len(parts) != 5:
|
||||
return cron_expr
|
||||
|
||||
minute, hour, day, month, dow = parts
|
||||
|
||||
descriptions = []
|
||||
if hour == '*' and minute == '*':
|
||||
descriptions.append("每分钟")
|
||||
elif hour == '*':
|
||||
descriptions.append(f"每小时的第 {minute} 分钟")
|
||||
elif day == '*' and month == '*' and dow == '*':
|
||||
descriptions.append(f"每天 {hour}:{minute:0>2}")
|
||||
else:
|
||||
descriptions.append(f"复杂的时间表: {cron_expr}")
|
||||
|
||||
return ", ".join(descriptions)
|
||||
@@ -0,0 +1,212 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import User
|
||||
from backend.dependencies import get_db, get_current_user, get_current_admin_user
|
||||
from backend.schemas.template import (
|
||||
TemplateCreate,
|
||||
TemplateUpdate,
|
||||
TemplateResponse,
|
||||
TaskFromTemplateRequest,
|
||||
TemplatePreviewResponse
|
||||
)
|
||||
from backend.schemas.task import TaskResponse
|
||||
from backend.services.template_service import TemplateService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TemplateResponse], summary="获取所有模板列表")
|
||||
async def get_all_templates(
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
|
||||
is_active: Optional[bool] = Query(None, description="过滤启用状态"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取所有模板列表(普通用户可访问)
|
||||
|
||||
- **skip**: 跳过记录数
|
||||
- **limit**: 限制记录数
|
||||
- **is_active**: 过滤启用状态
|
||||
"""
|
||||
try:
|
||||
templates = TemplateService.get_all_templates(db, skip, limit, is_active)
|
||||
return templates
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取模板列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/active", response_model=List[TemplateResponse], summary="获取启用的模板列表")
|
||||
async def get_active_templates(
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取所有启用的模板(用户创建任务时使用)
|
||||
|
||||
- **skip**: 跳过记录数
|
||||
- **limit**: 限制记录数
|
||||
"""
|
||||
try:
|
||||
templates = TemplateService.get_all_templates(db, skip, limit, is_active=True)
|
||||
return templates
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取模板列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{template_id}", response_model=TemplateResponse, summary="获取单个模板详情")
|
||||
async def get_template(
|
||||
template_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取单个模板的详细信息(普通用户只能访问启用的模板)
|
||||
|
||||
- **template_id**: 模板 ID
|
||||
"""
|
||||
template = TemplateService.get_template(template_id, db)
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="模板不存在"
|
||||
)
|
||||
|
||||
# 普通用户只能访问启用的模板
|
||||
if not current_user.is_admin and template.is_active is not True:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问此模板"
|
||||
)
|
||||
|
||||
return template
|
||||
|
||||
|
||||
@router.get("/{template_id}/preview", response_model=TemplatePreviewResponse, summary="预览模板生成的 payload")
|
||||
async def preview_template(
|
||||
template_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
预览模板生成的 payload(使用默认值,普通用户只能访问启用的模板)
|
||||
|
||||
- **template_id**: 模板 ID
|
||||
"""
|
||||
template = TemplateService.get_template(template_id, db)
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="模板不存在"
|
||||
)
|
||||
|
||||
# 普通用户只能访问启用的模板
|
||||
if not current_user.is_admin and template.is_active is not True:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问此模板"
|
||||
)
|
||||
|
||||
try:
|
||||
preview_payload = TemplateService.generate_preview_payload(template, db)
|
||||
# 使用合并后的配置
|
||||
merged_config = TemplateService.merge_parent_config(template, db)
|
||||
|
||||
return {
|
||||
"template_id": template.id,
|
||||
"template_name": template.name,
|
||||
"preview_payload": preview_payload,
|
||||
"field_config": merged_config
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"生成预览失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=TemplateResponse, summary="创建新模板(管理员)")
|
||||
async def create_template(
|
||||
template_data: TemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
创建新的打卡任务模板(仅管理员)
|
||||
|
||||
- **name**: 模板名称
|
||||
- **description**: 模板描述
|
||||
- **field_config**: 字段配置(JSON)
|
||||
- **is_active**: 是否启用
|
||||
"""
|
||||
return TemplateService.create_template(template_data, db)
|
||||
|
||||
|
||||
@router.put("/{template_id}", response_model=TemplateResponse, summary="更新模板(管理员)")
|
||||
async def update_template(
|
||||
template_id: int,
|
||||
template_data: TemplateUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
更新模板信息(仅管理员)
|
||||
|
||||
- **template_id**: 模板 ID
|
||||
- **name**: 模板名称
|
||||
- **description**: 模板描述
|
||||
- **field_config**: 字段配置(JSON)
|
||||
- **is_active**: 是否启用
|
||||
"""
|
||||
return TemplateService.update_template(template_id, template_data, db)
|
||||
|
||||
|
||||
@router.delete("/{template_id}", summary="删除模板(管理员)")
|
||||
async def delete_template(
|
||||
template_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
删除模板(仅管理员)
|
||||
|
||||
- **template_id**: 模板 ID
|
||||
"""
|
||||
TemplateService.delete_template(template_id, db)
|
||||
return {"message": "模板删除成功"}
|
||||
|
||||
|
||||
@router.post("/create-task", response_model=TaskResponse, summary="从模板创建任务")
|
||||
async def create_task_from_template(
|
||||
request: TaskFromTemplateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
从模板创建打卡任务
|
||||
|
||||
- **template_id**: 模板 ID
|
||||
- **thread_id**: 接龙项目 ID
|
||||
- **field_values**: 用户填写的字段值
|
||||
- **task_name**: 任务名称(可选)
|
||||
"""
|
||||
task = TemplateService.create_task_from_template(
|
||||
template_id=request.template_id,
|
||||
thread_id=request.thread_id,
|
||||
field_values=request.field_values,
|
||||
user_id=current_user.id,
|
||||
task_name=request.task_name,
|
||||
db=db
|
||||
)
|
||||
return task
|
||||
@@ -0,0 +1,294 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import get_db, User
|
||||
from backend.schemas.user import UserCreate, UserUpdate, UserResponse, TokenStatus, UserUpdateProfile
|
||||
from backend.schemas.task import TaskResponse
|
||||
from backend.services.user_service import UserService
|
||||
from backend.services.task_service import TaskService
|
||||
from backend.dependencies import get_current_user, get_current_admin_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED, summary="创建用户(管理员)")
|
||||
async def create_user(
|
||||
user_data: UserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
创建用户(需要管理员权限)
|
||||
|
||||
- **jwt_sub**: QQ 扫码登录的唯一用户标识
|
||||
- **alias**: 用户别名(用于登录)
|
||||
- **role**: 角色(可选,默认 "user")
|
||||
"""
|
||||
try:
|
||||
user = UserService.create_user(user_data, db)
|
||||
return user
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"创建用户失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse, summary="获取当前用户信息")
|
||||
async def get_current_user_info(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取当前登录用户的信息
|
||||
"""
|
||||
# 创建响应对象,手动添加 has_password 字段
|
||||
user_dict = {
|
||||
"id": current_user.id,
|
||||
"alias": current_user.alias,
|
||||
"jwt_sub": current_user.jwt_sub,
|
||||
"role": current_user.role,
|
||||
"is_approved": current_user.is_approved,
|
||||
"jwt_exp": current_user.jwt_exp,
|
||||
"email": current_user.email,
|
||||
"has_password": bool(current_user.password_hash),
|
||||
"created_at": current_user.created_at,
|
||||
"updated_at": current_user.updated_at,
|
||||
}
|
||||
return user_dict
|
||||
|
||||
|
||||
@router.get("/me/status", response_model=dict, summary="获取当前用户审批状态")
|
||||
async def get_user_status(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取用户审批状态(不要求审批通过)
|
||||
"""
|
||||
return {
|
||||
"user_id": current_user.id,
|
||||
"alias": current_user.alias,
|
||||
"is_approved": current_user.is_approved,
|
||||
"created_at": current_user.created_at.isoformat() if current_user.created_at else None
|
||||
}
|
||||
|
||||
|
||||
@router.put("/me/profile", response_model=UserResponse, summary="更新个人信息")
|
||||
async def update_current_user_profile(
|
||||
profile_data: UserUpdateProfile,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
更新当前用户的个人信息
|
||||
|
||||
- **alias**: 新别名(可选)
|
||||
- **current_password**: 当前密码(修改密码时必填)
|
||||
- **new_password**: 新密码(可选)
|
||||
|
||||
注意:
|
||||
- 修改密码时必须提供 current_password 和 new_password
|
||||
- 首次设置密码时不需要 current_password
|
||||
"""
|
||||
try:
|
||||
user = UserService.update_user_profile(current_user.id, profile_data, db)
|
||||
return user
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"更新个人信息失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/token_status", response_model=TokenStatus, summary="获取当前用户 Token 状态")
|
||||
async def get_current_user_token_status(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取当前用户的 Token 状态
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
is_valid = True
|
||||
days_until_expiry = None
|
||||
expires_at = None
|
||||
expiring_soon = False
|
||||
|
||||
if current_user.jwt_exp and current_user.jwt_exp != "0":
|
||||
try:
|
||||
exp_timestamp = int(current_user.jwt_exp)
|
||||
current_timestamp = int(datetime.now().timestamp())
|
||||
expires_at = exp_timestamp
|
||||
|
||||
if current_timestamp > exp_timestamp:
|
||||
is_valid = False
|
||||
else:
|
||||
days_until_expiry = (exp_timestamp - current_timestamp) // 86400
|
||||
# 检查是否在30分钟内过期
|
||||
minutes_until_expiry = (exp_timestamp - current_timestamp) // 60
|
||||
expiring_soon = minutes_until_expiry <= 30
|
||||
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"is_valid": is_valid,
|
||||
"jwt_exp": current_user.jwt_exp,
|
||||
"jwt_sub": current_user.jwt_sub,
|
||||
"expires_at": expires_at,
|
||||
"days_until_expiry": days_until_expiry,
|
||||
"expiring_soon": expiring_soon
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me/tasks", response_model=List[TaskResponse], summary="获取当前用户的任务列表")
|
||||
async def get_current_user_tasks(
|
||||
include_inactive: bool = Query(True, description="是否包含未启用的任务"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取当前登录用户的所有打卡任务
|
||||
|
||||
- **include_inactive**: 是否包含未启用的任务(默认 True)
|
||||
"""
|
||||
try:
|
||||
tasks = TaskService.get_user_tasks(current_user.id, db, include_inactive)
|
||||
return tasks
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取任务列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[UserResponse], summary="获取所有用户(管理员)")
|
||||
async def get_all_users(
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
|
||||
search: Optional[str] = Query(None, description="搜索关键词(alias 或 jwt_sub)"),
|
||||
role: Optional[str] = Query(None, description="过滤角色 (user/admin)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
获取所有用户列表(需要管理员权限)
|
||||
|
||||
- **skip**: 跳过记录数
|
||||
- **limit**: 限制记录数
|
||||
- **search**: 搜索关键词(模糊匹配 alias 或 jwt_sub)
|
||||
- **role**: 过滤角色(user/admin)
|
||||
"""
|
||||
try:
|
||||
users = UserService.get_all_users(db, skip, limit, search, role)
|
||||
return users
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取用户列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse, summary="获取指定用户")
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取指定用户信息
|
||||
|
||||
- 普通用户只能查看自己的信息
|
||||
- 管理员可以查看所有用户信息
|
||||
"""
|
||||
# 检查权限
|
||||
if current_user.role != "admin" and current_user.id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="权限不足,只能查看自己的信息"
|
||||
)
|
||||
|
||||
user = UserService.get_user_by_id(user_id, db)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"用户 ID {user_id} 不存在"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponse, summary="更新用户信息")
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
user_data: UserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
更新用户信息
|
||||
|
||||
- 普通用户只能更新自己的部分信息(不包括 role)
|
||||
- 管理员可以更新所有用户的所有信息
|
||||
"""
|
||||
# 检查权限
|
||||
if current_user.role != "admin":
|
||||
if current_user.id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="权限不足,只能更新自己的信息"
|
||||
)
|
||||
# 普通用户不能修改 role
|
||||
if user_data.role is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="普通用户不能修改角色"
|
||||
)
|
||||
|
||||
try:
|
||||
user = UserService.update_user(user_id, user_data, db)
|
||||
return user
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"更新用户失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, summary="删除用户(管理员)")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
删除用户(需要管理员权限)
|
||||
"""
|
||||
try:
|
||||
UserService.delete_user(user_id, db)
|
||||
return None
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"删除用户失败: {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import List
|
||||
|
||||
# 项目根目录
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用配置"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=str(BASE_DIR / ".env"),
|
||||
env_file_encoding='utf-8',
|
||||
case_sensitive=True,
|
||||
extra='ignore'
|
||||
)
|
||||
|
||||
# 项目根目录
|
||||
BASE_DIR: Path = BASE_DIR
|
||||
|
||||
# 项目基础配置
|
||||
PROJECT_NAME: str = "CheckIn API"
|
||||
VERSION: str = "2.0.0"
|
||||
API_PREFIX: str = "/api"
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL: str = f"sqlite:///{BASE_DIR}/data/checkin.db"
|
||||
|
||||
# CORS 配置(从环境变量读取,用逗号分隔)
|
||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> List[str]:
|
||||
"""将CORS_ORIGINS字符串转换为列表"""
|
||||
return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()]
|
||||
|
||||
# 日志配置
|
||||
LOG_FILE: Path = BASE_DIR / "logs" / "backend.log"
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
# 会话文件配置
|
||||
SESSION_DIR: Path = BASE_DIR / "sessions"
|
||||
SESSION_CLEANUP_HOURS: int = 24
|
||||
|
||||
# 邮件配置(从 .env 读取)
|
||||
SMTP_SERVER: str = ""
|
||||
SMTP_PORT: int = 465
|
||||
SMTP_SENDER_EMAIL: str = ""
|
||||
SMTP_SENDER_PASSWORD: str = ""
|
||||
SMTP_USE_SSL: bool = True
|
||||
|
||||
# 定时任务配置
|
||||
CHECKIN_SCHEDULE_HOUR: int = 20 # 20:00
|
||||
CHECKIN_SCHEDULE_MINUTE: int = 0
|
||||
TOKEN_CHECK_INTERVAL_MINUTES: int = 30
|
||||
SESSION_CLEANUP_INTERVAL_HOURS: int = 24
|
||||
|
||||
# Selenium / Chrome 配置(从 .env 读取)
|
||||
CHROME_BINARY_PATH: str = ""
|
||||
CHROMEDRIVER_PATH: str = ""
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,97 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, Header, status
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.models import get_db, User
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
authorization: Optional[str] = Header(None),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""
|
||||
获取当前用户
|
||||
从 Authorization header 中验证 Token 并返回用户
|
||||
"""
|
||||
if not authorization:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="未提供认证信息",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 移除 "Bearer " 前缀(如果存在)
|
||||
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
|
||||
|
||||
# 从数据库查询用户
|
||||
user = db.query(User).filter(User.authorization == token).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证信息",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 检查 Token 是否过期
|
||||
if user.jwt_exp and user.jwt_exp != "0":
|
||||
try:
|
||||
exp_timestamp = int(user.jwt_exp)
|
||||
current_timestamp = int(datetime.now().timestamp())
|
||||
if current_timestamp > exp_timestamp:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token 已过期,请重新登录",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
except ValueError:
|
||||
pass # jwt_exp 格式不正确,跳过验证
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def require_approved_user(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""
|
||||
要求用户已通过审批
|
||||
"""
|
||||
if not current_user.is_approved:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="您的账户正在等待管理员审批,请耐心等待(24小时内)"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_current_admin_user(
|
||||
current_user: User = Depends(require_approved_user)
|
||||
) -> User:
|
||||
"""
|
||||
获取当前管理员用户
|
||||
验证用户是否具有管理员权限
|
||||
"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="权限不足,需要管理员权限"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
authorization: Optional[str] = Header(None),
|
||||
db: Session = Depends(get_db)
|
||||
) -> Optional[User]:
|
||||
"""
|
||||
可选的用户认证
|
||||
如果提供了 Token 则返回用户,否则返回 None
|
||||
"""
|
||||
if not authorization:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await get_current_user(authorization, db)
|
||||
except HTTPException:
|
||||
return None
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from backend.config import settings
|
||||
from backend.models import init_db
|
||||
|
||||
# 配置日志
|
||||
settings.LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
logging.basicConfig(
|
||||
level=settings.LOG_LEVEL,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(settings.LOG_FILE, encoding="utf-8"),
|
||||
logging.StreamHandler(),
|
||||
],
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
# 启动时执行
|
||||
logger.info("正在启动 CheckIn API 服务...")
|
||||
|
||||
# 初始化数据库
|
||||
logger.info("正在初始化数据库...")
|
||||
init_db()
|
||||
logger.info("数据库初始化完成")
|
||||
|
||||
# 确保必要的目录存在
|
||||
settings.SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(settings.BASE_DIR / "data").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 启动调度器
|
||||
logger.info("正在启动调度器...")
|
||||
from backend.services.scheduler_service import start_scheduler
|
||||
start_scheduler()
|
||||
|
||||
logger.info(f"CheckIn API 服务已启动,版本: {settings.VERSION}")
|
||||
|
||||
yield
|
||||
|
||||
# 关闭时执行
|
||||
logger.info("正在关闭 CheckIn API 服务...")
|
||||
from backend.services.scheduler_service import stop_scheduler
|
||||
stop_scheduler()
|
||||
logger.info("CheckIn API 服务已关闭")
|
||||
|
||||
|
||||
# 创建 FastAPI 应用
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version=settings.VERSION,
|
||||
description="接龙自动打卡系统 API",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# 配置 CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list, # 使用属性方法获取列表
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# 健康检查端点
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""健康检查"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": settings.VERSION,
|
||||
"service": settings.PROJECT_NAME,
|
||||
}
|
||||
|
||||
|
||||
# 根路径
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""API 根路径"""
|
||||
return {
|
||||
"message": "欢迎使用接龙自动打卡系统 API",
|
||||
"version": settings.VERSION,
|
||||
"docs": "/docs",
|
||||
"health": "/health",
|
||||
}
|
||||
|
||||
|
||||
# 注册路由
|
||||
from backend.api import auth, users, check_in, admin, tasks, templates
|
||||
app.include_router(auth.router, prefix=f"{settings.API_PREFIX}/auth", tags=["认证"])
|
||||
app.include_router(users.router, prefix=f"{settings.API_PREFIX}/users", tags=["用户"])
|
||||
app.include_router(tasks.router, prefix=f"{settings.API_PREFIX}/tasks", tags=["打卡任务"])
|
||||
app.include_router(check_in.router, prefix=f"{settings.API_PREFIX}/check_in", tags=["打卡"])
|
||||
app.include_router(admin.router, prefix=f"{settings.API_PREFIX}/admin", tags=["管理员"])
|
||||
app.include_router(templates.router, prefix=f"{settings.API_PREFIX}/templates", tags=["任务模板"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=True,
|
||||
log_level="info",
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
from backend.models.database import Base, get_db, init_db
|
||||
from backend.models.user import User
|
||||
from backend.models.check_in_task import CheckInTask
|
||||
from backend.models.check_in_record import CheckInRecord
|
||||
from backend.models.task_template import TaskTemplate
|
||||
|
||||
__all__ = ["Base", "get_db", "init_db", "User", "CheckInTask", "CheckInRecord", "TaskTemplate"]
|
||||
@@ -0,0 +1,31 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from backend.models.database import Base
|
||||
|
||||
|
||||
class CheckInRecord(Base):
|
||||
"""打卡记录模型"""
|
||||
|
||||
__tablename__ = "check_in_records"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
task_id = Column(Integer, ForeignKey("check_in_tasks.id", ondelete="CASCADE"), nullable=False, index=True, comment="任务 ID")
|
||||
status = Column(String(20), nullable=False, index=True, comment="状态: success/failure/out_of_time/unknown/pending")
|
||||
response_text = Column(Text, default="", comment="响应文本")
|
||||
error_message = Column(Text, default="", comment="错误信息")
|
||||
location = Column(Text, default="{}", comment="位置信息 JSON")
|
||||
trigger_type = Column(String(50), default="scheduled", comment="触发类型: scheduled/manual/admin")
|
||||
check_in_time = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True, comment="打卡时间(UTC)")
|
||||
|
||||
# 关联任务
|
||||
task = relationship("CheckInTask", back_populates="check_in_records")
|
||||
|
||||
# 添加复合索引:加速常见查询
|
||||
__table_args__ = (
|
||||
Index('ix_record_task_time', 'task_id', 'check_in_time'), # 获取任务的打卡记录(按时间排序)
|
||||
Index('ix_record_status_time', 'status', 'check_in_time'), # 按状态和时间查询
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CheckInRecord(id={self.id}, task_id={self.task_id}, status={self.status})>"
|
||||
@@ -0,0 +1,39 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime, ForeignKey, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from backend.models.database import Base
|
||||
|
||||
|
||||
class CheckInTask(Base):
|
||||
"""打卡任务模型"""
|
||||
|
||||
__tablename__ = "check_in_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, comment="用户 ID")
|
||||
payload_config = Column(Text, default="{}", nullable=False, comment="完整的 payload 配置 JSON(从模板生成,包含 ThreadId 和所有字段)")
|
||||
name = Column(String(100), default="", comment="任务名称(用户自定义)")
|
||||
is_active = Column(Boolean, default=True, comment="是否启用自动打卡(不影响手动打卡)")
|
||||
cron_expression = Column(String(100), default="0 20 * * *", nullable=True, comment="Crontab 表达式(NULL 表示禁用自动打卡,否则按表达式执行)")
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
# 关联用户
|
||||
user = relationship("User", back_populates="tasks")
|
||||
|
||||
# 关联打卡记录
|
||||
check_in_records = relationship("CheckInRecord", back_populates="task", cascade="all, delete-orphan")
|
||||
|
||||
# 添加索引:加速查询
|
||||
__table_args__ = (
|
||||
Index('ix_task_user_active', 'user_id', 'is_active'),
|
||||
Index('ix_task_cron', 'cron_expression'), # 加速查询启用了定时打卡的任务
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CheckInTask(id={self.id}, user_id={self.user_id}, name={self.name}, cron={self.cron_expression})>"
|
||||
|
||||
@property
|
||||
def is_scheduled_enabled(self) -> bool:
|
||||
"""判断是否启用了自动打卡(is_active 为 True 且 cron_expression 不为空)"""
|
||||
return bool(self.is_active) and bool(self.cron_expression)
|
||||
@@ -0,0 +1,52 @@
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from datetime import datetime, timezone
|
||||
from backend.config import settings
|
||||
|
||||
# 创建数据库引擎
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
connect_args={"check_same_thread": False}, # SQLite 特定配置
|
||||
echo=False, # 生产环境设为 False
|
||||
)
|
||||
|
||||
# 创建会话工厂
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# 创建基类
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
# SQLite timezone 修复:在加载对象后,将所有 naive datetime 转换为 UTC timezone-aware
|
||||
@event.listens_for(Base, "load", propagate=True)
|
||||
def receive_load(target, context):
|
||||
"""在从数据库加载对象后,将所有 datetime 字段转换为 timezone-aware (UTC)"""
|
||||
for attr_name in dir(target):
|
||||
# 跳过私有属性和方法
|
||||
if attr_name.startswith('_'):
|
||||
continue
|
||||
|
||||
try:
|
||||
attr_value = getattr(target, attr_name)
|
||||
|
||||
# 如果是 naive datetime,添加 UTC timezone
|
||||
if isinstance(attr_value, datetime) and attr_value.tzinfo is None:
|
||||
setattr(target, attr_name, attr_value.replace(tzinfo=timezone.utc))
|
||||
except (AttributeError, TypeError):
|
||||
# 某些属性可能无法访问或设置,跳过
|
||||
continue
|
||||
|
||||
|
||||
def get_db():
|
||||
"""依赖注入:获取数据库会话"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""初始化数据库:创建所有表"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -0,0 +1,56 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from datetime import datetime, timezone
|
||||
from backend.config import settings
|
||||
import sqlite3
|
||||
|
||||
# SQLite 类型转换器:将从数据库读取的字符串转换为 timezone-aware datetime
|
||||
def convert_timestamp(val):
|
||||
"""将从数据库读取的字符串转换为 timezone-aware datetime (UTC)"""
|
||||
if val is None:
|
||||
return None
|
||||
# 解析 ISO 8601 格式的字符串
|
||||
try:
|
||||
dt = datetime.fromisoformat(val.decode() if isinstance(val, bytes) else val)
|
||||
# 如果是 naive datetime,添加 UTC timezone
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
# 注册 SQLite 类型转换器(全局)
|
||||
sqlite3.register_converter("DATETIME", convert_timestamp)
|
||||
sqlite3.register_converter("TIMESTAMP", convert_timestamp)
|
||||
|
||||
# 创建数据库引擎
|
||||
# 为 SQLite 连接添加 detect_types 参数以启用类型转换
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
connect_args={
|
||||
"check_same_thread": False, # SQLite 特定配置
|
||||
"detect_types": sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES # 启用类型转换
|
||||
},
|
||||
echo=False, # 生产环境设为 False
|
||||
)
|
||||
|
||||
# 创建会话工厂
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# 创建基类
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""依赖注入:获取数据库会话"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""初始化数据库:创建所有表"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -0,0 +1,29 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from backend.models.database import Base
|
||||
|
||||
|
||||
class TaskTemplate(Base):
|
||||
"""打卡任务模板"""
|
||||
__tablename__ = "task_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, comment="模板名称")
|
||||
description = Column(Text, nullable=True, comment="模板描述")
|
||||
|
||||
# 父模板 ID(用于继承)
|
||||
parent_id = Column(Integer, ForeignKey("task_templates.id", ondelete="SET NULL"), nullable=True, comment="父模板 ID")
|
||||
|
||||
# 字段配置(JSON 格式)
|
||||
field_config = Column(Text, nullable=False, comment="字段配置(JSON)")
|
||||
|
||||
is_active = Column(Boolean, default=True, comment="是否启用")
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
# 自引用关系:父模板和子模板
|
||||
parent = relationship("TaskTemplate", remote_side=[id], backref="children")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TaskTemplate(id={self.id}, name='{self.name}', is_active={self.is_active})>"
|
||||
@@ -0,0 +1,39 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from backend.models.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""用户模型 - 账户信息"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
jwt_sub = Column(String(200), unique=True, nullable=True, index=True, comment="QQ 扫码登录的唯一用户标识(注册时为空)")
|
||||
alias = Column(String(50), unique=True, nullable=False, index=True, comment="用户别名(用于登录)")
|
||||
email = Column(String(100), nullable=True, comment="用户邮箱(用于接收通知)")
|
||||
password_hash = Column(String(200), nullable=True, comment="密码哈希(bcrypt加密)")
|
||||
authorization = Column(Text, nullable=True, comment="当前有效的 QQ Token")
|
||||
jwt_exp = Column(String(20), default="0", comment="Token 过期时间戳")
|
||||
role = Column(String(20), default="user", index=True, comment="角色: user/admin")
|
||||
is_approved = Column(Boolean, default=False, index=True, comment="是否已通过管理员审批")
|
||||
registered_ip = Column(String(50), nullable=True, comment="注册时的 IP 地址")
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
# 关联打卡任务
|
||||
tasks = relationship("CheckInTask", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
# 添加复合索引:加速审批管理查询
|
||||
__table_args__ = (
|
||||
Index('ix_user_role_approved', 'role', 'is_approved'), # 管理员查询待审批用户
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, alias={self.alias}, jwt_sub={self.jwt_sub}, role={self.role})>"
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""判断是否为管理员"""
|
||||
return self.role == "admin"
|
||||
@@ -0,0 +1,16 @@
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
sqlalchemy>=2.0.25
|
||||
pydantic>=2.5.3
|
||||
pydantic-settings>=2.1.0
|
||||
python-dotenv>=1.0.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
python-multipart>=0.0.6
|
||||
apscheduler>=3.10.4
|
||||
filelock>=3.13.1
|
||||
selenium>=4.16.0
|
||||
pillow>=10.4.0
|
||||
requests>=2.31.0
|
||||
pyjwt>=2.8.0
|
||||
bcrypt>=4.1.2
|
||||
croniter>=1.3.8
|
||||
@@ -0,0 +1,71 @@
|
||||
from backend.schemas.user import (
|
||||
UserBase,
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
UserResponse,
|
||||
UserWithToken,
|
||||
TokenStatus,
|
||||
)
|
||||
from backend.schemas.auth import (
|
||||
QRCodeRequest,
|
||||
QRCodeResponse,
|
||||
QRCodeStatusResponse,
|
||||
TokenVerifyRequest,
|
||||
TokenVerifyResponse,
|
||||
)
|
||||
from backend.schemas.check_in import (
|
||||
ManualCheckInRequest,
|
||||
BatchCheckInRequest,
|
||||
CheckInRecordResponse,
|
||||
CheckInRecordWithTaskInfo,
|
||||
CheckInResultResponse,
|
||||
)
|
||||
from backend.schemas.task import (
|
||||
TaskBase,
|
||||
TaskCreate,
|
||||
TaskUpdate,
|
||||
TaskResponse,
|
||||
)
|
||||
from backend.schemas.template import (
|
||||
FieldOption,
|
||||
FieldConfigItem,
|
||||
FieldConfig,
|
||||
TemplateBase,
|
||||
TemplateCreate,
|
||||
TemplateUpdate,
|
||||
TemplateResponse,
|
||||
TaskFromTemplateRequest,
|
||||
TemplatePreviewResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"UserBase",
|
||||
"UserCreate",
|
||||
"UserUpdate",
|
||||
"UserResponse",
|
||||
"UserWithToken",
|
||||
"TokenStatus",
|
||||
"QRCodeRequest",
|
||||
"QRCodeResponse",
|
||||
"QRCodeStatusResponse",
|
||||
"TokenVerifyRequest",
|
||||
"TokenVerifyResponse",
|
||||
"ManualCheckInRequest",
|
||||
"BatchCheckInRequest",
|
||||
"CheckInRecordResponse",
|
||||
"CheckInRecordWithTaskInfo",
|
||||
"CheckInResultResponse",
|
||||
"TaskBase",
|
||||
"TaskCreate",
|
||||
"TaskUpdate",
|
||||
"TaskResponse",
|
||||
"FieldOption",
|
||||
"FieldConfigItem",
|
||||
"FieldConfig",
|
||||
"TemplateBase",
|
||||
"TemplateCreate",
|
||||
"TemplateUpdate",
|
||||
"TemplateResponse",
|
||||
"TaskFromTemplateRequest",
|
||||
"TemplatePreviewResponse",
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class QRCodeRequest(BaseModel):
|
||||
"""请求二维码 Schema"""
|
||||
alias: str = Field(..., description="用户别名")
|
||||
|
||||
|
||||
class QRCodeResponse(BaseModel):
|
||||
"""二维码响应 Schema"""
|
||||
session_id: str = Field(..., description="会话 ID")
|
||||
qrcode_image: str = Field(..., description="二维码 Base64 图片")
|
||||
|
||||
|
||||
class QRCodeStatusResponse(BaseModel):
|
||||
"""二维码状态响应 Schema"""
|
||||
status: str = Field(..., description="状态: pending/waiting_scan/success/error")
|
||||
message: Optional[str] = Field(None, description="状态消息")
|
||||
user_id: Optional[int] = Field(None, description="用户 ID (扫码成功时返回)")
|
||||
authorization: Optional[str] = Field(None, description="Token (扫码成功时返回)")
|
||||
qrcode_image: Optional[str] = Field(None, description="二维码 Base64 图片(等待扫描时返回)")
|
||||
|
||||
|
||||
class TokenVerifyRequest(BaseModel):
|
||||
"""Token 验证请求 Schema"""
|
||||
authorization: str = Field(..., description="Token")
|
||||
|
||||
|
||||
class TokenVerifyResponse(BaseModel):
|
||||
"""Token 验证响应 Schema"""
|
||||
is_valid: bool = Field(..., description="Token 是否有效")
|
||||
message: str = Field(..., description="验证消息")
|
||||
user_id: Optional[int] = Field(None, description="用户 ID")
|
||||
|
||||
|
||||
class AliasLoginRequest(BaseModel):
|
||||
"""别名+密码登录请求 Schema"""
|
||||
alias: str = Field(..., min_length=2, max_length=50, description="用户别名")
|
||||
password: str = Field(..., min_length=6, description="密码")
|
||||
|
||||
|
||||
class AliasLoginResponse(BaseModel):
|
||||
"""别名+密码登录响应 Schema"""
|
||||
success: bool = Field(..., description="登录是否成功")
|
||||
message: str = Field(..., description="登录消息")
|
||||
user_id: Optional[int] = Field(None, description="用户 ID")
|
||||
authorization: Optional[str] = Field(None, description="Token")
|
||||
alias: Optional[str] = Field(None, description="用户别名")
|
||||
@@ -0,0 +1,48 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class ManualCheckInRequest(BaseModel):
|
||||
"""手动打卡请求 Schema(已废弃,现在使用路径参数 task_id)"""
|
||||
task_id: Optional[int] = Field(None, description="任务 ID")
|
||||
|
||||
|
||||
class BatchCheckInRequest(BaseModel):
|
||||
"""批量打卡请求 Schema"""
|
||||
task_ids: list[int] = Field(..., description="任务 ID 列表")
|
||||
|
||||
|
||||
class CheckInRecordResponse(BaseModel):
|
||||
"""打卡记录响应 Schema"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
task_id: int
|
||||
status: str
|
||||
response_text: str
|
||||
error_message: str
|
||||
location: str
|
||||
trigger_type: str
|
||||
check_in_time: datetime # Pydantic v2 自动序列化为 ISO 8601 格式
|
||||
|
||||
# 新增字段:用户和任务信息(用于管理员查看)
|
||||
user_id: Optional[int] = Field(None, description="用户 ID")
|
||||
user_email: Optional[str] = Field(None, description="用户邮箱")
|
||||
task_name: Optional[str] = Field(None, description="任务名称")
|
||||
thread_id: Optional[str] = Field(None, description="接龙 ID")
|
||||
|
||||
|
||||
class CheckInRecordWithTaskInfo(CheckInRecordResponse):
|
||||
"""带任务信息的打卡记录响应 Schema"""
|
||||
task_name: str
|
||||
task_signature: str
|
||||
user_alias: str
|
||||
|
||||
|
||||
class CheckInResultResponse(BaseModel):
|
||||
"""打卡结果响应 Schema"""
|
||||
success: bool
|
||||
message: str
|
||||
record_id: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
@@ -0,0 +1,153 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import json
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class TaskBase(BaseModel):
|
||||
"""打卡任务基础 Schema"""
|
||||
payload_config: str = Field(..., description="完整的 payload 配置 JSON(包含 ThreadId 和所有字段)")
|
||||
name: Optional[str] = Field("", max_length=100, description="任务名称(用户自定义)")
|
||||
is_active: Optional[bool] = Field(True, description="是否启用自动打卡")
|
||||
|
||||
@field_validator('payload_config')
|
||||
@classmethod
|
||||
def validate_payload_config(cls, v: str) -> str:
|
||||
"""
|
||||
验证 payload_config 是否为有效的 JSON,并且包含必需的 ThreadId 字段
|
||||
"""
|
||||
if not v or not v.strip():
|
||||
raise ValueError("payload_config 不能为空")
|
||||
|
||||
try:
|
||||
payload = json.loads(v)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"payload_config 必须是有效的 JSON 格式: {str(e)}")
|
||||
|
||||
# 检查是否为字典类型
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("payload_config 必须是 JSON 对象(字典)")
|
||||
|
||||
# 检查必需字段 ThreadId
|
||||
if 'ThreadId' not in payload:
|
||||
raise ValueError("payload_config 必须包含 ThreadId 字段")
|
||||
|
||||
thread_id = payload.get('ThreadId')
|
||||
if not thread_id or not str(thread_id).strip():
|
||||
raise ValueError("ThreadId 不能为空")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class TaskCreate(TaskBase):
|
||||
"""创建打卡任务 Schema"""
|
||||
cron_expression: Optional[str] = Field(
|
||||
None,
|
||||
max_length=100,
|
||||
description="Crontab 表达式(例如 '0 20 * * *' 表示每天 20:00)。NULL 表示禁用定时打卡"
|
||||
)
|
||||
|
||||
@field_validator('cron_expression')
|
||||
@classmethod
|
||||
def validate_cron_expression(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""验证 Crontab 表达式格式"""
|
||||
if v is None:
|
||||
return v # NULL 允许(表示禁用定时打卡)
|
||||
|
||||
if not v.strip():
|
||||
raise ValueError("cron_expression 不能为空字符串,应该使用 NULL")
|
||||
|
||||
try:
|
||||
from croniter import croniter
|
||||
if not croniter.is_valid(v):
|
||||
raise ValueError(f"无效的 Crontab 表达式: '{v}'")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Crontab 表达式验证失败: {str(e)}")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
"""更新打卡任务 Schema"""
|
||||
payload_config: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
cron_expression: Optional[str] = Field(
|
||||
None,
|
||||
max_length=100,
|
||||
description="Crontab 表达式。NULL 表示禁用定时打卡"
|
||||
)
|
||||
|
||||
@field_validator('payload_config')
|
||||
@classmethod
|
||||
def validate_payload_config(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
验证 payload_config 是否为有效的 JSON(如果提供的话)
|
||||
"""
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
if not v.strip():
|
||||
raise ValueError("payload_config 不能为空字符串")
|
||||
|
||||
try:
|
||||
payload = json.loads(v)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"payload_config 必须是有效的 JSON 格式: {str(e)}")
|
||||
|
||||
# 检查是否为字典类型
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("payload_config 必须是 JSON 对象(字典)")
|
||||
|
||||
# 检查必需字段 ThreadId
|
||||
if 'ThreadId' not in payload:
|
||||
raise ValueError("payload_config 必须包含 ThreadId 字段")
|
||||
|
||||
thread_id = payload.get('ThreadId')
|
||||
if not thread_id or not str(thread_id).strip():
|
||||
raise ValueError("ThreadId 不能为空")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator('cron_expression')
|
||||
@classmethod
|
||||
def validate_cron_expression(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""验证 Crontab 表达式(与 TaskCreate 相同)"""
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
if not v.strip():
|
||||
raise ValueError("cron_expression 不能为空字符串,应该使用 NULL")
|
||||
|
||||
try:
|
||||
from croniter import croniter
|
||||
if not croniter.is_valid(v):
|
||||
raise ValueError(f"无效的 Crontab 表达式: '{v}'")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Crontab 表达式验证失败: {str(e)}")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class TaskResponse(TaskBase):
|
||||
"""打卡任务响应 Schema"""
|
||||
id: int
|
||||
user_id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
cron_expression: Optional[str] = Field(
|
||||
None,
|
||||
description="当前 Crontab 表达式(NULL = 禁用定时打卡)"
|
||||
)
|
||||
is_scheduled_enabled: Optional[bool] = Field(
|
||||
None,
|
||||
description="是否启用了定时打卡"
|
||||
)
|
||||
|
||||
# 新增字段:最后一次打卡信息
|
||||
last_check_in_time: Optional[datetime] = Field(None, description="最后一次打卡时间")
|
||||
last_check_in_status: Optional[str] = Field(None, description="最后一次打卡状态")
|
||||
thread_id: Optional[str] = Field(None, description="接龙 ID(从 payload_config 中提取)")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,147 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List, Union
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
import json
|
||||
|
||||
|
||||
class FieldOption(BaseModel):
|
||||
"""字段选项(用于 select 类型)"""
|
||||
label: str = Field(..., description="选项显示文本")
|
||||
value: str = Field(..., description="选项值")
|
||||
|
||||
|
||||
class FieldConfigItem(BaseModel):
|
||||
"""单个字段配置项"""
|
||||
display_name: str = Field(..., description="字段显示名称")
|
||||
field_type: str = Field(..., description="字段输入类型:text, textarea, number, select")
|
||||
default_value: str = Field(default="", description="默认值")
|
||||
required: bool = Field(default=True, description="是否必填")
|
||||
hidden: bool = Field(default=False, description="是否隐藏(直接使用默认值)")
|
||||
placeholder: Optional[str] = Field(None, description="输入提示")
|
||||
value_type: str = Field(default="string", description="值类型:string, int, double")
|
||||
options: Optional[List[FieldOption]] = Field(None, description="选项列表(仅 select 类型)")
|
||||
|
||||
@field_validator('field_type')
|
||||
@classmethod
|
||||
def validate_field_type(cls, v):
|
||||
allowed_types = ['text', 'textarea', 'number', 'select']
|
||||
if v not in allowed_types:
|
||||
raise ValueError(f'field_type must be one of {allowed_types}')
|
||||
return v
|
||||
|
||||
@field_validator('value_type')
|
||||
@classmethod
|
||||
def validate_value_type(cls, v):
|
||||
allowed_types = ['string', 'int', 'double']
|
||||
if v not in allowed_types:
|
||||
raise ValueError(f'value_type must be one of {allowed_types}')
|
||||
return v
|
||||
|
||||
|
||||
class FieldConfigValues(BaseModel):
|
||||
"""Values 字段的嵌套配置(如 location, temperature 等)"""
|
||||
pass
|
||||
|
||||
class Config:
|
||||
extra = 'allow' # 允许任意字段
|
||||
|
||||
|
||||
class FieldConfig(BaseModel):
|
||||
"""完整的字段配置"""
|
||||
signature: Optional[FieldConfigItem] = None
|
||||
texts: Optional[FieldConfigItem] = None
|
||||
values: Optional[Dict[str, FieldConfigItem]] = Field(None, description="Values 字段的嵌套配置")
|
||||
|
||||
|
||||
class TemplateBase(BaseModel):
|
||||
"""模板基础 Schema"""
|
||||
name: str = Field(..., min_length=1, max_length=100, description="模板名称")
|
||||
description: Optional[str] = Field(None, description="模板描述")
|
||||
parent_id: Optional[int] = Field(None, description="父模板 ID(用于继承)")
|
||||
field_config: Union[str, FieldConfig] = Field(..., description="字段配置(JSON 字符串或对象)")
|
||||
is_active: bool = Field(default=True, description="是否启用")
|
||||
|
||||
@field_validator('field_config')
|
||||
@classmethod
|
||||
def validate_field_config(cls, v):
|
||||
"""验证并转换 field_config"""
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
# 尝试解析 JSON 字符串
|
||||
config_dict = json.loads(v)
|
||||
return json.dumps(config_dict) # 返回格式化的 JSON 字符串
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError('field_config must be valid JSON string')
|
||||
elif isinstance(v, dict):
|
||||
# 如果是字典,转换为 JSON 字符串
|
||||
return json.dumps(v)
|
||||
elif isinstance(v, FieldConfig):
|
||||
# 如果是 FieldConfig 对象,转换为 JSON 字符串
|
||||
return v.model_dump_json(exclude_none=True)
|
||||
else:
|
||||
raise ValueError('field_config must be JSON string, dict, or FieldConfig object')
|
||||
|
||||
|
||||
class TemplateCreate(TemplateBase):
|
||||
"""创建模板 Schema"""
|
||||
pass
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
"""更新模板 Schema"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100, description="模板名称")
|
||||
description: Optional[str] = Field(None, description="模板描述")
|
||||
parent_id: Optional[int] = Field(None, description="父模板 ID(用于继承)")
|
||||
field_config: Optional[Union[str, FieldConfig]] = Field(None, description="字段配置(JSON 字符串或对象)")
|
||||
is_active: Optional[bool] = Field(None, description="是否启用")
|
||||
|
||||
@field_validator('field_config')
|
||||
@classmethod
|
||||
def validate_field_config(cls, v):
|
||||
"""验证并转换 field_config"""
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
config_dict = json.loads(v)
|
||||
return json.dumps(config_dict)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError('field_config must be valid JSON string')
|
||||
elif isinstance(v, dict):
|
||||
return json.dumps(v)
|
||||
elif isinstance(v, FieldConfig):
|
||||
return v.model_dump_json(exclude_none=True)
|
||||
else:
|
||||
raise ValueError('field_config must be JSON string, dict, or FieldConfig object')
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
"""模板响应 Schema"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
parent_id: Optional[int]
|
||||
field_config: str # JSON 字符串
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TaskFromTemplateRequest(BaseModel):
|
||||
"""从模板创建任务的请求 Schema"""
|
||||
template_id: int = Field(..., description="模板 ID")
|
||||
thread_id: str = Field(..., min_length=1, description="接龙项目 ID")
|
||||
field_values: Dict[str, Any] = Field(default_factory=dict, description="用户填写的字段值")
|
||||
task_name: Optional[str] = Field(None, max_length=100, description="任务名称(可选)")
|
||||
|
||||
|
||||
class TemplatePreviewResponse(BaseModel):
|
||||
"""模板预览响应 Schema"""
|
||||
template_id: int
|
||||
template_name: str
|
||||
preview_payload: Dict[str, Any] = Field(..., description="预览生成的 payload(使用默认值)")
|
||||
field_config: Dict[str, Any] = Field(..., description="字段配置(用于前端渲染表单)")
|
||||
@@ -0,0 +1,64 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""用户基础 Schema"""
|
||||
alias: str = Field(..., min_length=2, max_length=50, description="用户别名(用于登录)")
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""创建用户 Schema(管理员手动创建,只需要别名)"""
|
||||
role: Optional[str] = Field("user", description="角色: user/admin")
|
||||
email: Optional[str] = Field(None, description="邮箱地址")
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""更新用户 Schema(管理员编辑用户)"""
|
||||
alias: Optional[str] = Field(None, min_length=2, max_length=50, description="用户别名")
|
||||
role: Optional[str] = None
|
||||
is_approved: Optional[bool] = None
|
||||
email: Optional[str] = None
|
||||
password: Optional[str] = Field(None, min_length=6, description="新密码(可选,留空表示不修改)")
|
||||
reset_password: Optional[bool] = Field(False, description="是否清空密码")
|
||||
|
||||
|
||||
class UserUpdateProfile(BaseModel):
|
||||
"""用户更新个人信息 Schema"""
|
||||
alias: Optional[str] = Field(None, min_length=2, max_length=50, description="新别名")
|
||||
email: Optional[str] = Field(None, description="邮箱地址")
|
||||
current_password: Optional[str] = Field(None, min_length=6, description="当前密码(修改密码时必填)")
|
||||
new_password: Optional[str] = Field(None, min_length=6, description="新密码")
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""用户响应 Schema"""
|
||||
id: int
|
||||
alias: str
|
||||
jwt_sub: str
|
||||
role: str
|
||||
is_approved: bool
|
||||
jwt_exp: str
|
||||
email: Optional[str] = None
|
||||
has_password: bool = False # 是否已设置密码
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserWithToken(UserResponse):
|
||||
"""带 Token 的用户响应 Schema"""
|
||||
authorization: Optional[str] = None
|
||||
|
||||
|
||||
class TokenStatus(BaseModel):
|
||||
"""Token 状态 Schema"""
|
||||
is_valid: bool
|
||||
jwt_exp: str
|
||||
jwt_sub: str
|
||||
expires_at: Optional[int] = None # Unix 时间戳(秒)
|
||||
days_until_expiry: Optional[int] = None
|
||||
expiring_soon: bool = False # 是否即将过期(30分钟内)
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
创建管理员用户的脚本
|
||||
|
||||
使用方法:
|
||||
python backend/scripts/create_admin.py
|
||||
|
||||
或使用虚拟环境:
|
||||
./venv/Scripts/python.exe backend/scripts/create_admin.py
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到路径
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
|
||||
from backend.models import init_db, User
|
||||
from backend.models.database import SessionLocal
|
||||
from backend.services.auth_service import AuthService
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_admin_user(alias: str):
|
||||
"""
|
||||
将现有用户升级为管理员(或创建管理员占位符)
|
||||
|
||||
Args:
|
||||
alias: 用户别名
|
||||
"""
|
||||
# 初始化数据库
|
||||
init_db()
|
||||
|
||||
# 创建数据库会话
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# 检查别名是否已存在
|
||||
existing_user = db.query(User).filter(User.alias == alias).first()
|
||||
|
||||
if existing_user:
|
||||
print(f"[OK] 找到用户:{alias}")
|
||||
print(f" 用户 ID: {existing_user.id}")
|
||||
print(f" QQ 标识 (jwt_sub): {existing_user.jwt_sub}")
|
||||
print(f" 当前角色: {existing_user.role}")
|
||||
print(f" 审批状态: {existing_user.is_approved}")
|
||||
|
||||
# 如果已经是管理员
|
||||
if existing_user.role == "admin":
|
||||
print("\n该用户已经是管理员")
|
||||
return
|
||||
|
||||
# 升级为管理员
|
||||
response = input("\n是否将该用户升级为管理员?(y/n): ")
|
||||
if response.lower() == 'y':
|
||||
existing_user.role = "admin"
|
||||
existing_user.is_approved = True # 确保已审批
|
||||
db.commit()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("[成功] 用户已升级为管理员!")
|
||||
print("=" * 60)
|
||||
print(f" 用户 ID: {existing_user.id}")
|
||||
print(f" 别名: {existing_user.alias}")
|
||||
print(f" QQ 标识: {existing_user.jwt_sub}")
|
||||
print(f" 角色: admin")
|
||||
print("=" * 60)
|
||||
else:
|
||||
print("操作已取消")
|
||||
else:
|
||||
print(f"\n[错误] 未找到别名为 '{alias}' 的用户")
|
||||
print("\n请先使用该别名进行 QQ 扫码注册,然后再运行此脚本升级为管理员")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[错误] 操作失败: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
print("=" * 60)
|
||||
print("接龙自动打卡系统 - 设置管理员")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("[说明]")
|
||||
print(" 此脚本将已注册的用户升级为管理员")
|
||||
print(" 请先使用别名进行 QQ 扫码注册,然后运行此脚本")
|
||||
print()
|
||||
|
||||
# 获取用户别名
|
||||
alias = input("请输入要设置为管理员的用户别名 [admin]: ").strip() or "admin"
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"准备将用户 '{alias}' 设置为管理员")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
create_admin_user(alias)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
数据库迁移脚本:为 task_templates 表添加 parent_id 字段
|
||||
|
||||
运行方法:
|
||||
python backend/scripts/migrate_add_parent_id_to_templates.py
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 设置 UTF-8 编码输出(Windows 兼容)
|
||||
if sys.platform == "win32":
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from sqlalchemy import text
|
||||
from backend.models.database import engine, SessionLocal
|
||||
|
||||
|
||||
def migrate():
|
||||
"""为 task_templates 表添加 parent_id 字段"""
|
||||
print("=" * 60)
|
||||
print("开始数据库迁移:添加 parent_id 字段到 task_templates 表")
|
||||
print("=" * 60)
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# 检查字段是否已存在
|
||||
result = db.execute(text(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('task_templates') WHERE name='parent_id'"
|
||||
))
|
||||
field_exists = result.fetchone()[0] > 0
|
||||
|
||||
if field_exists:
|
||||
print("⚠️ parent_id 字段已存在,跳过迁移")
|
||||
return
|
||||
|
||||
# 添加 parent_id 字段
|
||||
print("📝 正在添加 parent_id 字段...")
|
||||
db.execute(text(
|
||||
"ALTER TABLE task_templates ADD COLUMN parent_id INTEGER"
|
||||
))
|
||||
db.commit()
|
||||
print("✅ parent_id 字段添加成功")
|
||||
|
||||
# 创建外键约束(SQLite 不支持直接添加外键,需要重建表)
|
||||
print("\n📝 注意:SQLite 不支持直接添加外键约束")
|
||||
print(" 如需外键约束,请重建表或在下次完整迁移时处理")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ 数据库迁移完成!")
|
||||
print("=" * 60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 迁移失败: {str(e)}")
|
||||
db.rollback()
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
添加 payload_config 字段到 check_in_tasks 表的迁移脚本
|
||||
|
||||
运行方式:
|
||||
python backend/scripts/migrate_add_payload_config.py
|
||||
或
|
||||
.venv/Scripts/python.exe backend/scripts/migrate_add_payload_config.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
project_root = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from sqlalchemy import text
|
||||
from backend.models.database import engine
|
||||
|
||||
|
||||
def migrate():
|
||||
"""执行迁移"""
|
||||
print("开始迁移:添加 payload_config 字段...")
|
||||
|
||||
with engine.connect() as conn:
|
||||
# 检查字段是否已存在
|
||||
result = conn.execute(text("PRAGMA table_info(check_in_tasks)"))
|
||||
columns = [row[1] for row in result]
|
||||
|
||||
if 'payload_config' in columns:
|
||||
print("[OK] payload_config 字段已存在,跳过迁移")
|
||||
return
|
||||
|
||||
# 添加 payload_config 字段(JSON 文本,存储完整的 payload 配置)
|
||||
print("添加 payload_config 字段...")
|
||||
conn.execute(text("""
|
||||
ALTER TABLE check_in_tasks
|
||||
ADD COLUMN payload_config TEXT DEFAULT '{}' NOT NULL
|
||||
"""))
|
||||
conn.commit()
|
||||
|
||||
print("[OK] payload_config 字段添加成功")
|
||||
print("\n注意:现有任务的 payload_config 默认为空 JSON {},")
|
||||
print(" Worker 将使用默认的固定字段值。")
|
||||
print(" 新创建的任务将从模板继承完整的 payload 配置。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
migrate()
|
||||
print("\n[SUCCESS] 迁移完成!")
|
||||
except Exception as e:
|
||||
print(f"\n[ERROR] 迁移失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
删除 check_in_tasks 表中不再需要的旧列的迁移脚本
|
||||
|
||||
删除的列:
|
||||
- signature (VARCHAR) - 已在 payload_config 中
|
||||
- texts (VARCHAR) - 已在 payload_config 中
|
||||
- values (TEXT) - 已在 payload_config 中
|
||||
- thread_id (VARCHAR) - 已在 payload_config 的 ThreadId 中
|
||||
- email (VARCHAR) - 从 user 表的 email 字段获取
|
||||
|
||||
新架构只保留:
|
||||
- id, user_id, payload_config, name, is_active, created_at, updated_at
|
||||
|
||||
运行方式:
|
||||
python backend/scripts/migrate_remove_old_columns.py
|
||||
或
|
||||
venv/Scripts/python.exe backend/scripts/migrate_remove_old_columns.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
project_root = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from sqlalchemy import text, inspect
|
||||
from backend.models.database import engine
|
||||
|
||||
|
||||
def migrate():
|
||||
"""执行迁移:删除旧列"""
|
||||
print("开始迁移:删除 check_in_tasks 表中的旧列...")
|
||||
print("将删除的列: signature, texts, values, thread_id, email")
|
||||
print("=" * 60)
|
||||
|
||||
with engine.connect() as conn:
|
||||
# 检查表结构
|
||||
inspector = inspect(engine)
|
||||
columns = [col['name'] for col in inspector.get_columns('check_in_tasks')]
|
||||
|
||||
print(f"\n当前表列: {', '.join(columns)}")
|
||||
|
||||
old_columns = ['signature', 'texts', 'values', 'thread_id', 'email']
|
||||
columns_to_remove = [col for col in old_columns if col in columns]
|
||||
|
||||
if not columns_to_remove:
|
||||
print("\n[OK] 旧列已被删除,跳过迁移")
|
||||
return
|
||||
|
||||
print(f"\n需要删除的列: {', '.join(columns_to_remove)}")
|
||||
|
||||
# SQLite 不支持直接 DROP COLUMN,需要重建表
|
||||
# 步骤:
|
||||
# 1. 创建新表(只包含需要的列)
|
||||
# 2. 复制数据
|
||||
# 3. 删除旧表
|
||||
# 4. 重命名新表
|
||||
|
||||
print("\n正在重建表结构...")
|
||||
|
||||
# 1. 创建新表
|
||||
conn.execute(text("""
|
||||
CREATE TABLE check_in_tasks_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
payload_config TEXT NOT NULL DEFAULT '{}',
|
||||
name VARCHAR(100) DEFAULT '',
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""))
|
||||
print(" [OK] 创建新表结构")
|
||||
|
||||
# 2. 复制数据(只复制保留的列)
|
||||
conn.execute(text("""
|
||||
INSERT INTO check_in_tasks_new
|
||||
(id, user_id, payload_config, name, is_active, created_at, updated_at)
|
||||
SELECT
|
||||
id, user_id, payload_config, name, is_active, created_at, updated_at
|
||||
FROM check_in_tasks
|
||||
"""))
|
||||
print(" [OK] 复制数据到新表")
|
||||
|
||||
# 3. 删除旧表
|
||||
conn.execute(text("DROP TABLE check_in_tasks"))
|
||||
print(" [OK] 删除旧表")
|
||||
|
||||
# 4. 重命名新表
|
||||
conn.execute(text("ALTER TABLE check_in_tasks_new RENAME TO check_in_tasks"))
|
||||
print(" [OK] 重命名新表")
|
||||
|
||||
# 5. 重建索引
|
||||
conn.execute(text("""
|
||||
CREATE INDEX ix_check_in_tasks_user_id ON check_in_tasks(user_id)
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
CREATE INDEX ix_check_in_tasks_id ON check_in_tasks(id)
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
CREATE INDEX ix_task_user_active ON check_in_tasks(user_id, is_active)
|
||||
"""))
|
||||
print(" [OK] 重建索引")
|
||||
|
||||
conn.commit()
|
||||
|
||||
print("\n[SUCCESS] 表结构迁移成功!")
|
||||
print("\n新的表结构:")
|
||||
inspector = inspect(engine)
|
||||
new_columns = [col['name'] for col in inspector.get_columns('check_in_tasks')]
|
||||
print(f" 列: {', '.join(new_columns)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
migrate()
|
||||
print("\n" + "=" * 60)
|
||||
print("[完成] 迁移成功完成!")
|
||||
print("\n数据库已更新为新架构:")
|
||||
print(" - 删除了 signature, texts, values, thread_id, email 列")
|
||||
print(" - 保留了 payload_config 列(存储完整的 JSON payload)")
|
||||
print(" - ThreadId 现在存储在 payload_config 中")
|
||||
print(" - Email 现在从 user 表获取")
|
||||
print("=" * 60)
|
||||
except Exception as e:
|
||||
print(f"\n[ERROR] 迁移失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,85 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminService:
|
||||
"""管理员服务"""
|
||||
|
||||
@staticmethod
|
||||
def get_pending_users(db: Session) -> List[User]:
|
||||
"""获取待审批用户列表"""
|
||||
users = db.query(User).filter(
|
||||
User.is_approved == False,
|
||||
User.role == "user"
|
||||
).order_by(User.created_at.desc()).all()
|
||||
|
||||
return users
|
||||
|
||||
@staticmethod
|
||||
def approve_user(user_id: int, db: Session) -> Dict[str, Any]:
|
||||
"""审批通过用户"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
if not user:
|
||||
return {"success": False, "message": "用户不存在"}
|
||||
|
||||
if user.is_approved:
|
||||
return {"success": False, "message": "用户已经通过审批"}
|
||||
|
||||
user.is_approved = True
|
||||
user.updated_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
logger.info(f"管理员审批通过用户: {user.alias} (ID: {user.id})")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "审批成功",
|
||||
"user_id": user.id
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def reject_user(user_id: int, db: Session) -> Dict[str, Any]:
|
||||
"""拒绝并删除用户"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
if not user:
|
||||
return {"success": False, "message": "用户不存在"}
|
||||
|
||||
alias = user.alias
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"管理员拒绝用户: {alias} (ID: {user_id})")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "已拒绝并删除用户"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def delete_expired_pending_users(db: Session) -> int:
|
||||
"""删除24小时未审批的用户"""
|
||||
cutoff_time = datetime.now() - timedelta(hours=24)
|
||||
|
||||
expired_users = db.query(User).filter(
|
||||
User.is_approved == False,
|
||||
User.role == "user",
|
||||
User.created_at < cutoff_time
|
||||
).all()
|
||||
|
||||
count = len(expired_users)
|
||||
|
||||
for user in expired_users:
|
||||
logger.info(f"删除过期未审批用户: {user.alias} (ID: {user.id})")
|
||||
db.delete(user)
|
||||
|
||||
db.commit()
|
||||
|
||||
return count
|
||||
@@ -0,0 +1,477 @@
|
||||
import uuid
|
||||
import logging
|
||||
import threading
|
||||
import jwt
|
||||
import bcrypt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from urllib.parse import unquote
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import User
|
||||
from backend.workers.token_refresher import get_token_headless, get_session_data
|
||||
from backend.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""认证服务"""
|
||||
|
||||
@staticmethod
|
||||
def request_qrcode(alias: str, client_ip: str, db: Session) -> Dict[str, Any]:
|
||||
"""
|
||||
请求 QQ 扫码二维码(支持新用户注册)
|
||||
|
||||
Args:
|
||||
alias: 用户别名
|
||||
client_ip: 客户端 IP 地址
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
包含 session_id 和 qrcode_base64 的字典
|
||||
"""
|
||||
from backend.services.registration_manager import registration_manager
|
||||
import time
|
||||
|
||||
# 检查用户名是否已在数据库中存在
|
||||
existing_user = db.query(User).filter(User.alias == alias).first()
|
||||
|
||||
# 生成唯一的会话 ID
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
if existing_user:
|
||||
# 检查是否为空 jwt_sub(测试账号)
|
||||
if not existing_user.jwt_sub or existing_user.jwt_sub == "":
|
||||
logger.warning(f"用户 {alias} 是测试账号(空 jwt_sub),禁止登录")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "此账户为测试账号,暂未绑定 QQ,无法登录"
|
||||
}
|
||||
|
||||
# 老用户:刷新 Token
|
||||
logger.info(f"老用户 {alias} 请求刷新 Token,会话: {session_id}")
|
||||
|
||||
# 在后台线程启动 Selenium,传入 jwt_sub
|
||||
thread = threading.Thread(
|
||||
target=get_token_headless,
|
||||
args=(session_id, existing_user.jwt_sub, alias, client_ip),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
else:
|
||||
# 新用户:预占用户名
|
||||
if not registration_manager.reserve_alias(alias, session_id, timeout_seconds=120):
|
||||
logger.warning(f"用户名 {alias} 已被预占")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "该用户名正在被其他人注册,请稍后再试或更换用户名"
|
||||
}
|
||||
|
||||
logger.info(f"新用户 {alias} 请求注册,会话: {session_id},已预占用户名")
|
||||
|
||||
# 在后台线程启动 Selenium,不传入 jwt_sub(新用户)
|
||||
thread = threading.Thread(
|
||||
target=get_token_headless,
|
||||
args=(session_id, None, alias, client_ip),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
# 等待二维码生成(最多等待 30 秒)
|
||||
logger.info(f"等待会话 {session_id} 的二维码生成...")
|
||||
max_wait_time = 30
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < max_wait_time:
|
||||
session_data = get_session_data(session_id)
|
||||
|
||||
if session_data:
|
||||
status = session_data.get("status")
|
||||
|
||||
# 二维码已生成
|
||||
if status == "waiting_scan":
|
||||
qr_image_data = session_data.get("qr_image_data")
|
||||
if qr_image_data:
|
||||
logger.info(f"会话 {session_id} 的二维码已生成")
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"qrcode_base64": qr_image_data
|
||||
}
|
||||
|
||||
# 如果已经失败,直接返回错误
|
||||
elif status == "failed":
|
||||
error_msg = session_data.get("message", "生成二维码失败")
|
||||
logger.error(f"会话 {session_id} 生成二维码失败: {error_msg}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": error_msg
|
||||
}
|
||||
|
||||
# 每 0.5 秒检查一次
|
||||
time.sleep(0.5)
|
||||
|
||||
# 超时
|
||||
logger.error(f"会话 {session_id} 等待二维码生成超时({max_wait_time}秒)")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"生成二维码超时,请重试"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_qrcode_status(session_id: str, db: Session) -> Dict[str, Any]:
|
||||
"""
|
||||
检查二维码扫描状态
|
||||
|
||||
Args:
|
||||
session_id: 会话 ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
包含状态信息的字典
|
||||
"""
|
||||
session_data = get_session_data(session_id)
|
||||
|
||||
if not session_data:
|
||||
return {
|
||||
"status": "pending",
|
||||
"message": "会话不存在或正在初始化"
|
||||
}
|
||||
|
||||
status = session_data.get("status")
|
||||
jwt_sub = session_data.get("jwt_sub") # 使用 jwt_sub 而非 signature
|
||||
|
||||
if status == "waiting_scan":
|
||||
return {
|
||||
"status": "waiting_scan",
|
||||
"message": "请使用手机 QQ 扫描二维码",
|
||||
"qrcode_image": session_data.get("qr_image_data")
|
||||
}
|
||||
|
||||
elif status == "success":
|
||||
token = session_data.get("token")
|
||||
alias = session_data.get("alias") # 新增:从 session 中获取 alias
|
||||
|
||||
# 解析 JWT Token 获取 jwt_exp 和 jwt_sub
|
||||
jwt_exp = "0"
|
||||
jwt_sub = ""
|
||||
|
||||
if not token:
|
||||
logger.error("Token 为空")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Token 为空"
|
||||
}
|
||||
|
||||
try:
|
||||
# 清洗 Token:URL 解码 + 去除 Bearer 前缀(参考 v1 实现)
|
||||
pure_token = unquote(token) # URL 解码
|
||||
if pure_token.lower().startswith('bearer '):
|
||||
pure_token = pure_token[7:] # 去除 "Bearer " 前缀
|
||||
|
||||
decoded = jwt.decode(pure_token, options={"verify_signature": False})
|
||||
jwt_exp = str(decoded.get("exp", 0))
|
||||
jwt_sub = decoded.get("sub", "")
|
||||
logger.info(f"成功解析 JWT for sub={jwt_sub}, exp={jwt_exp}")
|
||||
except Exception as e:
|
||||
logger.error(f"解析 JWT Token 失败: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Token 解析失败: {str(e)}"
|
||||
}
|
||||
|
||||
# 查找用户(通过 jwt_sub)
|
||||
user = db.query(User).filter(User.jwt_sub == jwt_sub).first()
|
||||
|
||||
if user:
|
||||
# 老用户:更新 Token(存储清理后的 token)
|
||||
# 注意:如果通过别名登录,需要验证 jwt_sub 是否匹配
|
||||
if alias and alias == user.alias:
|
||||
# 用户使用别名登录,验证 jwt_sub 是否一致
|
||||
# 如果用户之前的 jwt_sub 不为空且与当前不一致,说明QQ号被换绑了
|
||||
existing_jwt_sub = getattr(user, 'jwt_sub', '')
|
||||
if isinstance(existing_jwt_sub, str) and existing_jwt_sub.strip() and existing_jwt_sub != jwt_sub:
|
||||
logger.warning(f"⚠️ 用户 {user.alias} 的 jwt_sub 不匹配!数据库: {existing_jwt_sub}, 当前: {jwt_sub}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "QQ账号不匹配,请使用正确的QQ号扫码登录"
|
||||
}
|
||||
|
||||
user.authorization = pure_token # 存储清理后的 token
|
||||
user.jwt_exp = jwt_exp
|
||||
user.updated_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info(f"更新老用户 {user.alias} 的 Token")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "登录成功",
|
||||
"token": pure_token, # 返回清理后的 token
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"alias": user.alias,
|
||||
"role": user.role,
|
||||
"is_approved": user.is_approved,
|
||||
"jwt_sub": user.jwt_sub
|
||||
},
|
||||
"is_new_user": False
|
||||
}
|
||||
|
||||
else:
|
||||
# 新用户:创建账户
|
||||
from backend.services.registration_manager import registration_manager
|
||||
|
||||
# 验证用户名是否被预占
|
||||
if not alias or not registration_manager.is_alias_reserved(alias):
|
||||
logger.error(f"新用户注册失败:用户名 {alias} 未预占或已过期")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "注册失败:会话已过期,请重新扫码"
|
||||
}
|
||||
|
||||
# 检查用户名是否已被其他人注册(防止竞态)
|
||||
existing_user_by_alias = db.query(User).filter(User.alias == alias).first()
|
||||
if existing_user_by_alias:
|
||||
registration_manager.release_alias(alias)
|
||||
logger.error(f"新用户注册失败:用户名 {alias} 已被占用")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "注册失败:用户名已被占用,请更换用户名"
|
||||
}
|
||||
|
||||
# 创建新用户(待审批状态)
|
||||
client_ip = session_data.get("client_ip", "")
|
||||
new_user = User(
|
||||
jwt_sub=jwt_sub,
|
||||
alias=alias,
|
||||
authorization=pure_token, # 存储清理后的 token
|
||||
jwt_exp=jwt_exp,
|
||||
role="user",
|
||||
is_approved=False, # 待审批
|
||||
registered_ip=client_ip
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
# 释放用户名预占
|
||||
registration_manager.release_alias(alias)
|
||||
|
||||
logger.info(f"✅ 新用户 {alias} 注册成功(待审批),ID: {new_user.id}")
|
||||
|
||||
# 发送邮件通知管理员
|
||||
try:
|
||||
from backend.services.email_service import EmailService
|
||||
EmailService.notify_new_user_registration(new_user, db)
|
||||
except Exception as e:
|
||||
logger.error(f"发送注册通知邮件失败: {e}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "注册成功,请等待管理员审批(24小时内)",
|
||||
"token": pure_token, # 返回清理后的 token
|
||||
"user": {
|
||||
"id": new_user.id,
|
||||
"alias": new_user.alias,
|
||||
"role": new_user.role,
|
||||
"is_approved": new_user.is_approved,
|
||||
"jwt_sub": new_user.jwt_sub
|
||||
},
|
||||
"is_new_user": True
|
||||
}
|
||||
|
||||
elif status == "error":
|
||||
return {
|
||||
"status": "error",
|
||||
"message": session_data.get("message", "未知错误")
|
||||
}
|
||||
|
||||
else:
|
||||
return {
|
||||
"status": "pending",
|
||||
"message": "正在初始化..."
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def verify_token(authorization: str, db: Session) -> Dict[str, Any]:
|
||||
"""
|
||||
验证 Token 有效性
|
||||
|
||||
Args:
|
||||
authorization: Token
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
包含验证结果的字典
|
||||
"""
|
||||
# 移除 "Bearer " 前缀
|
||||
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
|
||||
|
||||
# 从数据库查询用户
|
||||
user = db.query(User).filter(User.authorization == token).first()
|
||||
|
||||
if not user:
|
||||
return {
|
||||
"is_valid": False,
|
||||
"message": "Token 无效"
|
||||
}
|
||||
|
||||
# 检查 Token 是否过期
|
||||
if user.jwt_exp and user.jwt_exp != "0":
|
||||
try:
|
||||
exp_timestamp = int(user.jwt_exp)
|
||||
current_timestamp = int(datetime.now().timestamp())
|
||||
|
||||
if current_timestamp > exp_timestamp:
|
||||
return {
|
||||
"is_valid": False,
|
||||
"message": "Token 已过期",
|
||||
"user_id": user.id
|
||||
}
|
||||
|
||||
# 计算剩余天数
|
||||
days_until_expiry = (exp_timestamp - current_timestamp) // 86400
|
||||
|
||||
return {
|
||||
"is_valid": True,
|
||||
"message": "Token 有效",
|
||||
"user_id": user.id,
|
||||
"days_until_expiry": days_until_expiry
|
||||
}
|
||||
|
||||
except ValueError:
|
||||
logger.error(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}")
|
||||
|
||||
return {
|
||||
"is_valid": True,
|
||||
"message": "Token 有效",
|
||||
"user_id": user.id
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def alias_login(alias: str, password: str, db: Session) -> Dict[str, Any]:
|
||||
"""
|
||||
别名+密码登录
|
||||
|
||||
Args:
|
||||
alias: 用户别名
|
||||
password: 密码
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
包含登录结果的字典
|
||||
"""
|
||||
# 查找用户
|
||||
user = db.query(User).filter(User.alias == alias).first()
|
||||
|
||||
if not user:
|
||||
logger.warning(f"别名登录失败:用户 {alias} 不存在")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "用户名或密码错误"
|
||||
}
|
||||
|
||||
# 检查用户是否设置了密码
|
||||
if not user.password_hash:
|
||||
logger.warning(f"别名登录失败:用户 {alias} 未设置密码")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "该用户未设置密码,请使用扫码登录"
|
||||
}
|
||||
|
||||
# 验证密码
|
||||
try:
|
||||
password_bytes = password.encode('utf-8')
|
||||
hash_bytes = user.password_hash.encode('utf-8')
|
||||
|
||||
if not bcrypt.checkpw(password_bytes, hash_bytes):
|
||||
logger.warning(f"别名登录失败:用户 {alias} 密码错误")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "用户名或密码错误"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"密码验证异常:{e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "登录失败,请稍后重试"
|
||||
}
|
||||
|
||||
# 检查 Token 状态(仅作提示,不阻止登录)
|
||||
token_warning = None
|
||||
|
||||
if not user.authorization or user.jwt_exp == "0":
|
||||
logger.info(f"用户 {alias} Token 无效,允许密码登录但需提示用户更新")
|
||||
token_warning = "token_invalid"
|
||||
else:
|
||||
# 检查 Token 是否过期
|
||||
try:
|
||||
exp_timestamp = int(user.jwt_exp)
|
||||
current_timestamp = int(datetime.now().timestamp())
|
||||
|
||||
if current_timestamp > exp_timestamp:
|
||||
logger.info(f"用户 {alias} Token 已过期,允许密码登录但需提示用户更新")
|
||||
token_warning = "token_expired"
|
||||
except ValueError:
|
||||
logger.error(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}")
|
||||
|
||||
# 登录成功
|
||||
logger.info(f"✅ 用户 {alias} (ID: {user.id}) 别名登录成功")
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"message": "登录成功",
|
||||
"user_id": user.id,
|
||||
"authorization": user.authorization,
|
||||
"alias": user.alias
|
||||
}
|
||||
|
||||
# 如果 Token 有问题,添加警告信息
|
||||
if token_warning:
|
||||
result["token_warning"] = token_warning
|
||||
if token_warning == "token_invalid":
|
||||
result["warning_message"] = "登录成功,但检测到登录凭证无效,部分功能可能受限,建议扫码更新"
|
||||
elif token_warning == "token_expired":
|
||||
result["warning_message"] = "登录成功,但检测到登录凭证已过期,部分功能可能受限,建议扫码更新"
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
"""
|
||||
使用 bcrypt 加密密码
|
||||
|
||||
Args:
|
||||
password: 明文密码
|
||||
|
||||
Returns:
|
||||
加密后的密码哈希
|
||||
"""
|
||||
password_bytes = password.encode('utf-8')
|
||||
salt = bcrypt.gensalt()
|
||||
hash_bytes = bcrypt.hashpw(password_bytes, salt)
|
||||
return hash_bytes.decode('utf-8')
|
||||
|
||||
@staticmethod
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""
|
||||
验证密码
|
||||
|
||||
Args:
|
||||
password: 明文密码
|
||||
password_hash: 密码哈希
|
||||
|
||||
Returns:
|
||||
密码是否正确
|
||||
"""
|
||||
try:
|
||||
password_bytes = password.encode('utf-8')
|
||||
hash_bytes = password_hash.encode('utf-8')
|
||||
return bcrypt.checkpw(password_bytes, hash_bytes)
|
||||
except Exception as e:
|
||||
logger.error(f"密码验证异常:{e}")
|
||||
return False
|
||||
@@ -0,0 +1,588 @@
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
import json
|
||||
import threading
|
||||
|
||||
from backend.models import User, CheckInTask, CheckInRecord
|
||||
from backend.workers.check_in_worker import perform_check_in
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CheckInService:
|
||||
"""打卡服务"""
|
||||
|
||||
@staticmethod
|
||||
def create_pending_check_in_record(task: CheckInTask, trigger_type: str, db: Session) -> int:
|
||||
"""
|
||||
创建一个待处理的打卡记录并返回 record_id
|
||||
|
||||
Args:
|
||||
task: 打卡任务对象
|
||||
trigger_type: 触发类型 (manual/scheduled/admin)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
打卡记录 ID
|
||||
"""
|
||||
logger.info(f"🎯 创建待处理打卡记录 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id})")
|
||||
|
||||
# 创建一个 pending 状态的记录
|
||||
record = CheckInRecord(
|
||||
task_id=task.id,
|
||||
status="pending",
|
||||
response_text="",
|
||||
error_message="",
|
||||
location="{}",
|
||||
trigger_type=trigger_type
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
logger.info(f"✅ 创建待处理记录成功 - Record ID: {record.id}")
|
||||
return record.id
|
||||
|
||||
@staticmethod
|
||||
def execute_check_in_async(task_id: int, record_id: int, user_token: str):
|
||||
"""
|
||||
在后台线程中执行打卡操作
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
record_id: 打卡记录 ID
|
||||
user_token: 用户 Token
|
||||
"""
|
||||
from backend.models.database import SessionLocal
|
||||
|
||||
# 创建独立的数据库会话
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
logger.info(f"🤖 后台线程开始执行打卡 - Task ID: {task_id}, Record ID: {record_id}")
|
||||
|
||||
# 获取任务对象
|
||||
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
|
||||
if not task:
|
||||
logger.error(f"❌ 任务不存在 - Task ID: {task_id}")
|
||||
# 更新记录状态为失败
|
||||
record = db.query(CheckInRecord).filter(CheckInRecord.id == record_id).first()
|
||||
if record:
|
||||
db.query(CheckInRecord).filter(CheckInRecord.id == record_id).update({
|
||||
"status": "failure",
|
||||
"error_message": "任务不存在"
|
||||
})
|
||||
db.commit()
|
||||
return
|
||||
|
||||
# 执行打卡
|
||||
result = perform_check_in(task, user_token)
|
||||
|
||||
# 更新记录
|
||||
db.query(CheckInRecord).filter(CheckInRecord.id == record_id).update({
|
||||
"status": result["status"],
|
||||
"response_text": result["response_text"],
|
||||
"error_message": result["error_message"]
|
||||
})
|
||||
db.commit()
|
||||
|
||||
if result["success"]:
|
||||
logger.info(f"✅ 后台打卡成功 - Record ID: {record_id}")
|
||||
else:
|
||||
logger.error(f"❌ 后台打卡失败 - Record ID: {record_id}, 错误: {result['error_message']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"💥 后台打卡异常 - Task ID: {task_id}, Record ID: {record_id}, 错误: {str(e)}")
|
||||
# 更新记录状态
|
||||
try:
|
||||
db.query(CheckInRecord).filter(CheckInRecord.id == record_id).update({
|
||||
"status": "failure",
|
||||
"error_message": f"后台执行异常: {str(e)}"
|
||||
})
|
||||
db.commit()
|
||||
except Exception as inner_e:
|
||||
logger.error(f"💥 更新记录失败: {str(inner_e)}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@staticmethod
|
||||
def start_async_check_in(task: CheckInTask, trigger_type: str, db: Session) -> Dict[str, Any]:
|
||||
"""
|
||||
启动异步打卡任务
|
||||
|
||||
Args:
|
||||
task: 打卡任务对象
|
||||
trigger_type: 触发类型 (manual/scheduled/admin)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
包含 record_id 的字典
|
||||
"""
|
||||
logger.info(f"🚀 启动异步打卡 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id})")
|
||||
|
||||
# 获取用户的 Token
|
||||
user = task.user
|
||||
if not user or not user.authorization:
|
||||
error_msg = f"用户没有有效的 Token"
|
||||
logger.error(f"❌ {error_msg} - Task ID: {task.id}")
|
||||
|
||||
# 创建失败记录
|
||||
record = CheckInRecord(
|
||||
task_id=task.id,
|
||||
status="failure",
|
||||
response_text="",
|
||||
error_message=error_msg,
|
||||
location="{}",
|
||||
trigger_type=trigger_type
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
return {
|
||||
"record_id": record.id,
|
||||
"status": "failure",
|
||||
"message": error_msg
|
||||
}
|
||||
|
||||
# 检查 Token 是否过期
|
||||
if user.jwt_exp and user.jwt_exp != "0":
|
||||
try:
|
||||
exp_timestamp = int(user.jwt_exp)
|
||||
current_timestamp = int(datetime.now().timestamp())
|
||||
if current_timestamp > exp_timestamp:
|
||||
error_msg = f"Token 已过期"
|
||||
logger.warning(f"⏰ {error_msg} - Task ID: {task.id}")
|
||||
|
||||
record = CheckInRecord(
|
||||
task_id=task.id,
|
||||
status="failure",
|
||||
response_text="",
|
||||
error_message=f"{error_msg},请重新扫码登录",
|
||||
location="{}",
|
||||
trigger_type=trigger_type
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
return {
|
||||
"record_id": record.id,
|
||||
"status": "failure",
|
||||
"message": f"{error_msg},请重新扫码登录"
|
||||
}
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 创建待处理记录
|
||||
record_id = CheckInService.create_pending_check_in_record(task, trigger_type, db)
|
||||
|
||||
# 在后台线程中执行打卡
|
||||
import threading
|
||||
thread = threading.Thread(
|
||||
target=CheckInService.execute_check_in_async,
|
||||
args=(task.id, record_id, user.authorization),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
logger.info(f"✅ 异步打卡任务已启动 - Record ID: {record_id}")
|
||||
|
||||
return {
|
||||
"record_id": record_id,
|
||||
"status": "pending",
|
||||
"message": "打卡任务已启动,正在后台处理"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def perform_task_check_in(task: CheckInTask, trigger_type: str, db: Session) -> Dict[str, Any]:
|
||||
"""
|
||||
执行单个任务的打卡
|
||||
|
||||
Args:
|
||||
task: 打卡任务对象
|
||||
trigger_type: 触发类型 (manual/scheduled/admin)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
打卡结果字典
|
||||
"""
|
||||
logger.info(f"🎯 开始打卡 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id}), 触发: {trigger_type}")
|
||||
|
||||
# 获取用户的 Token
|
||||
user = task.user
|
||||
if not user or not user.authorization:
|
||||
error_msg = f"用户没有有效的 Token"
|
||||
logger.error(f"❌ {error_msg} - Task ID: {task.id}, User ID: {user.id if user else 'None'}")
|
||||
|
||||
# 记录失败
|
||||
record = CheckInRecord(
|
||||
task_id=task.id,
|
||||
status="failure",
|
||||
response_text="",
|
||||
error_message=error_msg,
|
||||
location="{}",
|
||||
trigger_type=trigger_type
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"message": error_msg,
|
||||
"record_id": record.id
|
||||
}
|
||||
|
||||
# 检查 Token 是否过期
|
||||
if user.jwt_exp and user.jwt_exp != "0":
|
||||
try:
|
||||
exp_timestamp = int(user.jwt_exp)
|
||||
current_timestamp = int(datetime.now().timestamp())
|
||||
if current_timestamp > exp_timestamp:
|
||||
error_msg = f"Token 已过期"
|
||||
expires_at = datetime.fromtimestamp(exp_timestamp)
|
||||
logger.warning(f"⏰ {error_msg} - 过期时间: {expires_at}, 用户: {user.alias}, Task ID: {task.id}")
|
||||
|
||||
# 记录失败
|
||||
record = CheckInRecord(
|
||||
task_id=task.id,
|
||||
status="failure",
|
||||
response_text="",
|
||||
error_message=error_msg,
|
||||
location="{}",
|
||||
trigger_type=trigger_type
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"{error_msg},请重新扫码登录",
|
||||
"record_id": record.id
|
||||
}
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 执行打卡(传递 task 对象和用户 token)
|
||||
logger.info(f"🤖 调用 Selenium Worker 执行打卡...")
|
||||
result = perform_check_in(task, user.authorization)
|
||||
|
||||
# 保存打卡记录
|
||||
record = CheckInRecord(
|
||||
task_id=task.id,
|
||||
status=result["status"],
|
||||
response_text=result["response_text"],
|
||||
error_message=result["error_message"],
|
||||
location="{}",
|
||||
trigger_type=trigger_type
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
if result["success"]:
|
||||
logger.info(f"✅ 打卡成功 - Record ID: {record.id}")
|
||||
else:
|
||||
logger.error(f"❌ 打卡失败 - {result['error_message']}")
|
||||
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": "打卡成功" if result["success"] else f"打卡失败: {result['error_message']}",
|
||||
"record_id": record.id
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def batch_check_in_tasks(task_ids: List[int], db: Session) -> Dict[str, Any]:
|
||||
"""
|
||||
批量打卡任务
|
||||
|
||||
Args:
|
||||
task_ids: 任务 ID 列表
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
批量打卡结果
|
||||
"""
|
||||
logger.info(f"🚀 开始批量打卡,任务数量: {len(task_ids)}")
|
||||
|
||||
results = {
|
||||
"total": len(task_ids),
|
||||
"success": 0,
|
||||
"failure": 0,
|
||||
"skipped": 0,
|
||||
"details": []
|
||||
}
|
||||
|
||||
# 优化:一次性查询所有任务,避免 N+1 查询
|
||||
tasks = db.query(CheckInTask).filter(CheckInTask.id.in_(task_ids)).all()
|
||||
tasks_dict = {task.id: task for task in tasks}
|
||||
|
||||
for task_id in task_ids:
|
||||
try:
|
||||
task = tasks_dict.get(task_id)
|
||||
if not task:
|
||||
logger.warning(f"⚠️ 任务 ID {task_id} 不存在,跳过")
|
||||
results["skipped"] += 1
|
||||
results["details"].append({
|
||||
"task_id": task_id,
|
||||
"success": False,
|
||||
"message": "任务不存在"
|
||||
})
|
||||
continue
|
||||
|
||||
# 执行打卡(移除 is_active 检查,允许手动打卡)
|
||||
result = CheckInService.perform_task_check_in(task, "admin", db)
|
||||
|
||||
if result["success"]:
|
||||
results["success"] += 1
|
||||
logger.info(f"✅ 任务 {task_id} 批量打卡成功")
|
||||
else:
|
||||
results["failure"] += 1
|
||||
logger.error(f"❌ 任务 {task_id} 批量打卡失败: {result['message']}")
|
||||
|
||||
results["details"].append({
|
||||
"task_id": task_id,
|
||||
"task_name": task.name or f'Task-{task.id}',
|
||||
"success": result["success"],
|
||||
"message": result["message"],
|
||||
"record_id": result.get("record_id")
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"💥 任务 {task_id} 处理异常: {str(e)}")
|
||||
results["failure"] += 1
|
||||
results["details"].append({
|
||||
"task_id": task_id,
|
||||
"success": False,
|
||||
"message": f"异常: {str(e)}"
|
||||
})
|
||||
|
||||
logger.info(f"📊 批量打卡完成 - 成功: {results['success']}, 失败: {results['failure']}, 跳过: {results['skipped']}")
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def scheduled_check_in_all_active_tasks(db: Session) -> Dict[str, Any]:
|
||||
"""
|
||||
定时任务:为所有启用的任务执行打卡
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
打卡结果统计
|
||||
"""
|
||||
logger.info("开始执行定时打卡任务...")
|
||||
|
||||
# 获取所有启用的任务(预加载用户信息)
|
||||
from sqlalchemy.orm import joinedload
|
||||
active_tasks = db.query(CheckInTask).options(
|
||||
joinedload(CheckInTask.user)
|
||||
).filter(CheckInTask.is_active == True).all()
|
||||
|
||||
logger.info(f"找到 {len(active_tasks)} 个启用的任务")
|
||||
|
||||
results = {
|
||||
"total": len(active_tasks),
|
||||
"success": 0,
|
||||
"failure": 0,
|
||||
"skipped": 0,
|
||||
"details": []
|
||||
}
|
||||
|
||||
for task in active_tasks:
|
||||
# 检查用户是否有 Token
|
||||
if not task.user or not task.user.authorization:
|
||||
logger.warning(f"任务 ID: {task.id} 的用户没有 Token,跳过")
|
||||
results["skipped"] += 1
|
||||
continue
|
||||
|
||||
# 检查 Token 是否过期
|
||||
if task.user.jwt_exp and task.user.jwt_exp != "0":
|
||||
try:
|
||||
exp_timestamp = int(task.user.jwt_exp)
|
||||
current_timestamp = int(datetime.now().timestamp())
|
||||
if current_timestamp > exp_timestamp:
|
||||
logger.warning(f"任务 ID: {task.id} 的用户 Token 已过期,跳过")
|
||||
results["skipped"] += 1
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 执行打卡
|
||||
result = CheckInService.perform_task_check_in(task, "scheduled", db)
|
||||
|
||||
if result["success"]:
|
||||
results["success"] += 1
|
||||
else:
|
||||
results["failure"] += 1
|
||||
|
||||
results["details"].append({
|
||||
"task_id": task.id,
|
||||
"task_name": task.name or f'Task-{task.id}',
|
||||
"success": result["success"],
|
||||
"message": result["message"]
|
||||
})
|
||||
|
||||
logger.info(f"定时打卡任务完成,成功: {results['success']}, 失败: {results['failure']}, 跳过: {results['skipped']}")
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def get_task_records(
|
||||
task_id: int,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[str] = None,
|
||||
trigger_type: Optional[str] = None
|
||||
) -> List[CheckInRecord]:
|
||||
"""
|
||||
获取任务的打卡记录
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
db: 数据库会话
|
||||
skip: 跳过记录数
|
||||
limit: 限制记录数
|
||||
status: 过滤状态 (success/failure)
|
||||
trigger_type: 过滤触发类型 (scheduler/manual)
|
||||
|
||||
Returns:
|
||||
打卡记录列表
|
||||
"""
|
||||
query = db.query(CheckInRecord).filter(CheckInRecord.task_id == task_id)
|
||||
|
||||
if status:
|
||||
query = query.filter(CheckInRecord.status == status)
|
||||
|
||||
if trigger_type:
|
||||
query = query.filter(CheckInRecord.trigger_type == trigger_type)
|
||||
|
||||
return query.order_by(
|
||||
CheckInRecord.check_in_time.desc()
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
@staticmethod
|
||||
def get_user_records(
|
||||
user_id: int,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[str] = None,
|
||||
trigger_type: Optional[str] = None
|
||||
) -> List[CheckInRecord]:
|
||||
"""
|
||||
获取用户的所有打卡记录
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
db: 数据库会话
|
||||
skip: 跳过记录数
|
||||
limit: 限制记录数
|
||||
status: 过滤状态 (success/failure)
|
||||
trigger_type: 过滤触发类型 (scheduler/manual)
|
||||
|
||||
Returns:
|
||||
打卡记录列表
|
||||
"""
|
||||
# 获取用户的所有任务ID
|
||||
user_task_ids = db.query(CheckInTask.id).filter(CheckInTask.user_id == user_id).all()
|
||||
task_ids = [task_id for (task_id,) in user_task_ids]
|
||||
|
||||
# 查询这些任务的打卡记录
|
||||
query = db.query(CheckInRecord).filter(CheckInRecord.task_id.in_(task_ids))
|
||||
|
||||
if status:
|
||||
query = query.filter(CheckInRecord.status == status)
|
||||
|
||||
if trigger_type:
|
||||
query = query.filter(CheckInRecord.trigger_type == trigger_type)
|
||||
|
||||
return query.order_by(
|
||||
CheckInRecord.check_in_time.desc()
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
@staticmethod
|
||||
def get_all_records(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
task_id: Optional[int] = None,
|
||||
status: Optional[str] = None
|
||||
) -> List[CheckInRecord]:
|
||||
"""
|
||||
获取所有打卡记录(管理员)
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
skip: 跳过记录数
|
||||
limit: 限制记录数
|
||||
task_id: 过滤任务 ID
|
||||
status: 过滤状态
|
||||
|
||||
Returns:
|
||||
打卡记录列表
|
||||
"""
|
||||
query = db.query(CheckInRecord)
|
||||
|
||||
if task_id:
|
||||
query = query.filter(CheckInRecord.task_id == task_id)
|
||||
|
||||
if status:
|
||||
query = query.filter(CheckInRecord.status == status)
|
||||
|
||||
return query.order_by(
|
||||
CheckInRecord.check_in_time.desc()
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
@staticmethod
|
||||
def enrich_record_with_user_task_info(record: CheckInRecord, db: Session) -> dict:
|
||||
"""
|
||||
为打卡记录添加用户和任务信息
|
||||
|
||||
Args:
|
||||
record: 打卡记录对象
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
包含额外信息的记录字典
|
||||
"""
|
||||
# 获取任务信息
|
||||
task = db.query(CheckInTask).filter(CheckInTask.id == record.task_id).first()
|
||||
|
||||
# 获取用户信息
|
||||
user = None
|
||||
task_name = None
|
||||
thread_id = None
|
||||
|
||||
if task:
|
||||
user = db.query(User).filter(User.id == task.user_id).first()
|
||||
task_name = task.name
|
||||
|
||||
# 从 payload_config 提取 ThreadId
|
||||
try:
|
||||
payload = json.loads(str(task.payload_config))
|
||||
thread_id = payload.get('ThreadId')
|
||||
except:
|
||||
pass
|
||||
|
||||
# 转换为字典并添加额外字段
|
||||
record_dict = {
|
||||
'id': record.id,
|
||||
'task_id': record.task_id,
|
||||
'status': record.status,
|
||||
'response_text': record.response_text,
|
||||
'error_message': record.error_message,
|
||||
'location': record.location,
|
||||
'trigger_type': record.trigger_type,
|
||||
'check_in_time': record.check_in_time,
|
||||
'user_id': user.id if user else None,
|
||||
'user_email': user.email if user else None,
|
||||
'task_name': task_name,
|
||||
'thread_id': thread_id,
|
||||
}
|
||||
|
||||
return record_dict
|
||||
@@ -0,0 +1,301 @@
|
||||
import smtplib
|
||||
import logging
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.config import settings
|
||||
from backend.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""邮件通知服务"""
|
||||
|
||||
@staticmethod
|
||||
def send_email(to_emails: List[str], subject: str, body_html: str) -> bool:
|
||||
"""
|
||||
发送邮件
|
||||
|
||||
Args:
|
||||
to_emails: 收件人邮箱列表
|
||||
subject: 邮件主题
|
||||
body_html: 邮件正文(HTML 格式)
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
# 检查邮件配置
|
||||
if not all([settings.SMTP_SERVER, settings.SMTP_SENDER_EMAIL, settings.SMTP_SENDER_PASSWORD]):
|
||||
logger.warning("邮件配置不完整,跳过发送邮件")
|
||||
return False
|
||||
|
||||
try:
|
||||
# 创建邮件
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['From'] = settings.SMTP_SENDER_EMAIL
|
||||
msg['To'] = ', '.join(to_emails)
|
||||
msg['Subject'] = subject
|
||||
|
||||
# 添加 HTML 正文
|
||||
html_part = MIMEText(body_html, 'html', 'utf-8')
|
||||
msg.attach(html_part)
|
||||
|
||||
# 连接 SMTP 服务器并发送
|
||||
if settings.SMTP_USE_SSL:
|
||||
server = smtplib.SMTP_SSL(settings.SMTP_SERVER, settings.SMTP_PORT)
|
||||
else:
|
||||
server = smtplib.SMTP(settings.SMTP_SERVER, settings.SMTP_PORT)
|
||||
server.starttls()
|
||||
|
||||
server.login(settings.SMTP_SENDER_EMAIL, settings.SMTP_SENDER_PASSWORD)
|
||||
server.sendmail(settings.SMTP_SENDER_EMAIL, to_emails, msg.as_string())
|
||||
server.quit()
|
||||
|
||||
logger.info(f"邮件发送成功: {subject} -> {', '.join(to_emails)}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"邮件发送失败: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def notify_new_user_registration(user: User, db: Session) -> bool:
|
||||
"""
|
||||
通知管理员有新用户注册
|
||||
|
||||
Args:
|
||||
user: 新注册的用户
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
# 查询所有管理员邮箱
|
||||
admins = db.query(User).filter(User.role == "admin", User.email.isnot(None)).all()
|
||||
admin_emails = [admin.email for admin in admins if admin.email]
|
||||
|
||||
if not admin_emails:
|
||||
logger.warning("没有找到管理员邮箱,无法发送通知")
|
||||
return False
|
||||
|
||||
# 构建邮件内容
|
||||
subject = f"【接龙自动打卡系统】新用户注册通知 - {user.alias}"
|
||||
|
||||
body_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}}
|
||||
.header {{
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-radius: 5px 5px 0 0;
|
||||
}}
|
||||
.content {{
|
||||
background-color: #f9f9f9;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 0 0 5px 5px;
|
||||
}}
|
||||
.info-table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
}}
|
||||
.info-table td {{
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}}
|
||||
.info-table td:first-child {{
|
||||
font-weight: bold;
|
||||
width: 120px;
|
||||
}}
|
||||
.footer {{
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}}
|
||||
.warning {{
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 10px;
|
||||
margin: 15px 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h2>🔔 新用户注册通知</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>尊敬的管理员,</p>
|
||||
<p>有新用户注册了接龙自动打卡系统,请及时审批。</p>
|
||||
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<td>用户名</td>
|
||||
<td>{user.alias}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>用户 ID</td>
|
||||
<td>{user.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>注册时间</td>
|
||||
<td>{user.created_at.strftime('%Y-%m-%d %H:%M:%S') if user.created_at else '未知'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>注册 IP</td>
|
||||
<td>{user.registered_ip or '未记录'}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="warning">
|
||||
<strong>⚠️ 重要提示:</strong>
|
||||
<p>该用户需要在 24 小时内通过审批,否则账户将被自动删除。</p>
|
||||
<p>请登录管理后台进行审批操作。</p>
|
||||
</div>
|
||||
|
||||
<p>登录地址:<a href="http://localhost:5173/admin/users">http://localhost:5173/admin/users</a></p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>此邮件由系统自动发送,请勿直接回复。</p>
|
||||
<p>接龙自动打卡系统 © {datetime.now().year}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return EmailService.send_email(admin_emails, subject, body_html)
|
||||
|
||||
@staticmethod
|
||||
def notify_check_in_result(user: User, task_info: dict, success: bool, message: str = "") -> bool:
|
||||
"""
|
||||
通知用户打卡结果
|
||||
|
||||
Args:
|
||||
user: 用户对象
|
||||
task_info: 打卡任务信息(包含 thread_id, texts, values 等)
|
||||
success: 打卡是否成功
|
||||
message: 额外消息
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
if not user.email:
|
||||
logger.info(f"用户 {user.alias} 未设置邮箱,跳过打卡通知")
|
||||
return False
|
||||
|
||||
# 构建邮件内容
|
||||
status_text = "✅ 成功" if success else "❌ 失败"
|
||||
status_color = "#28a745" if success else "#dc3545"
|
||||
|
||||
subject = f"【接龙自动打卡】打卡{status_text} - {user.alias}"
|
||||
|
||||
body_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}}
|
||||
.header {{
|
||||
background-color: {status_color};
|
||||
color: white;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-radius: 5px 5px 0 0;
|
||||
}}
|
||||
.content {{
|
||||
background-color: #f9f9f9;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 0 0 5px 5px;
|
||||
}}
|
||||
.info-table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
}}
|
||||
.info-table td {{
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}}
|
||||
.info-table td:first-child {{
|
||||
font-weight: bold;
|
||||
width: 120px;
|
||||
}}
|
||||
.footer {{
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h2>打卡通知 {status_text}</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>您好,{user.alias}!</p>
|
||||
<p>您的接龙自动打卡任务已执行。</p>
|
||||
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<td>执行时间</td>
|
||||
<td>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>任务 ID</td>
|
||||
<td>{task_info.get('thread_id', '未知')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>打卡状态</td>
|
||||
<td><strong style="color: {status_color};">{status_text}</strong></td>
|
||||
</tr>
|
||||
{f'<tr><td>详细信息</td><td>{message}</td></tr>' if message else ''}
|
||||
</table>
|
||||
|
||||
<p>如有问题,请及时检查您的打卡配置。</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>此邮件由系统自动发送,请勿直接回复。</p>
|
||||
<p>接龙自动打卡系统 © {datetime.now().year}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return EmailService.send_email([user.email], subject, body_html)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
用户名预占和注册限流管理器
|
||||
"""
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from typing import Optional, Dict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RegistrationManager:
|
||||
"""用户注册管理器 - 处理用户名预占和注册限流"""
|
||||
|
||||
def __init__(self):
|
||||
# 用户名预占记录: {alias: {session_id: str, expire_time: float}}
|
||||
self._reserved_aliases: Dict[str, Dict] = {}
|
||||
|
||||
# Cookie 注册限流记录: {cookie_value: expire_time}
|
||||
self._registration_cookies: Dict[str, float] = {}
|
||||
|
||||
# 线程锁
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# 启动清理线程
|
||||
self._start_cleanup_thread()
|
||||
|
||||
def reserve_alias(self, alias: str, session_id: str, timeout_seconds: int = 120) -> bool:
|
||||
"""
|
||||
预占用户名
|
||||
|
||||
Args:
|
||||
alias: 用户名
|
||||
session_id: 会话 ID
|
||||
timeout_seconds: 超时时间(秒),默认 120 秒(2 分钟)
|
||||
|
||||
Returns:
|
||||
是否预占成功
|
||||
"""
|
||||
with self._lock:
|
||||
current_time = time.time()
|
||||
expire_time = current_time + timeout_seconds
|
||||
|
||||
# 检查用户名是否已被预占
|
||||
if alias in self._reserved_aliases:
|
||||
reservation = self._reserved_aliases[alias]
|
||||
|
||||
# 检查是否过期
|
||||
if reservation['expire_time'] > current_time:
|
||||
# 未过期,检查是否是同一个 session
|
||||
if reservation['session_id'] == session_id:
|
||||
# 同一个 session,更新过期时间
|
||||
reservation['expire_time'] = expire_time
|
||||
logger.info(f"用户名 {alias} 预占时间已更新(session: {session_id})")
|
||||
return True
|
||||
else:
|
||||
# 不同 session,预占失败
|
||||
logger.warning(f"用户名 {alias} 已被占用(session: {reservation['session_id']})")
|
||||
return False
|
||||
|
||||
# 预占用户名
|
||||
self._reserved_aliases[alias] = {
|
||||
'session_id': session_id,
|
||||
'expire_time': expire_time
|
||||
}
|
||||
logger.info(f"用户名 {alias} 已预占(session: {session_id}, 超时: {timeout_seconds}s)")
|
||||
return True
|
||||
|
||||
def release_alias(self, alias: str, session_id: Optional[str] = None) -> bool:
|
||||
"""
|
||||
释放用户名预占
|
||||
|
||||
Args:
|
||||
alias: 用户名
|
||||
session_id: 会话 ID(可选,如果提供则只释放匹配的 session)
|
||||
|
||||
Returns:
|
||||
是否释放成功
|
||||
"""
|
||||
with self._lock:
|
||||
if alias not in self._reserved_aliases:
|
||||
return False
|
||||
|
||||
reservation = self._reserved_aliases[alias]
|
||||
|
||||
# 如果指定了 session_id,则只释放匹配的
|
||||
if session_id and reservation['session_id'] != session_id:
|
||||
logger.warning(f"尝试释放用户名 {alias},但 session 不匹配")
|
||||
return False
|
||||
|
||||
del self._reserved_aliases[alias]
|
||||
logger.info(f"用户名 {alias} 预占已释放")
|
||||
return True
|
||||
|
||||
def is_alias_reserved(self, alias: str) -> bool:
|
||||
"""
|
||||
检查用户名是否被预占
|
||||
|
||||
Args:
|
||||
alias: 用户名
|
||||
|
||||
Returns:
|
||||
是否被预占
|
||||
"""
|
||||
with self._lock:
|
||||
if alias not in self._reserved_aliases:
|
||||
return False
|
||||
|
||||
reservation = self._reserved_aliases[alias]
|
||||
current_time = time.time()
|
||||
|
||||
# 检查是否过期
|
||||
if reservation['expire_time'] <= current_time:
|
||||
# 已过期,自动释放
|
||||
del self._reserved_aliases[alias]
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def check_registration_cookie(self, cookie_value: str) -> bool:
|
||||
"""
|
||||
检查 Cookie 是否在限流期内
|
||||
|
||||
Args:
|
||||
cookie_value: Cookie 值
|
||||
|
||||
Returns:
|
||||
True 表示可以注册,False 表示在限流期内
|
||||
"""
|
||||
with self._lock:
|
||||
current_time = time.time()
|
||||
|
||||
# 检查 Cookie 是否存在
|
||||
if cookie_value in self._registration_cookies:
|
||||
expire_time = self._registration_cookies[cookie_value]
|
||||
|
||||
# 检查是否过期
|
||||
if expire_time > current_time:
|
||||
remaining = int(expire_time - current_time)
|
||||
logger.warning(f"Cookie {cookie_value[:8]}... 在限流期内(剩余 {remaining} 秒)")
|
||||
return False
|
||||
else:
|
||||
# 已过期,移除记录
|
||||
del self._registration_cookies[cookie_value]
|
||||
|
||||
return True
|
||||
|
||||
def record_registration(self, cookie_value: str, cooldown_seconds: int = 600) -> None:
|
||||
"""
|
||||
记录注册操作(10 分钟冷却)
|
||||
|
||||
Args:
|
||||
cookie_value: Cookie 值
|
||||
cooldown_seconds: 冷却时间(秒),默认 600 秒(10 分钟)
|
||||
"""
|
||||
with self._lock:
|
||||
current_time = time.time()
|
||||
expire_time = current_time + cooldown_seconds
|
||||
|
||||
self._registration_cookies[cookie_value] = expire_time
|
||||
logger.info(f"Cookie {cookie_value[:8]}... 已记录注册(冷却 {cooldown_seconds} 秒)")
|
||||
|
||||
def _cleanup_expired_records(self) -> None:
|
||||
"""清理过期的预占记录和限流记录"""
|
||||
with self._lock:
|
||||
current_time = time.time()
|
||||
|
||||
# 清理过期的用户名预占
|
||||
expired_aliases = [
|
||||
alias for alias, reservation in self._reserved_aliases.items()
|
||||
if reservation['expire_time'] <= current_time
|
||||
]
|
||||
|
||||
for alias in expired_aliases:
|
||||
del self._reserved_aliases[alias]
|
||||
logger.debug(f"用户名 {alias} 预占已过期,自动释放")
|
||||
|
||||
# 清理过期的注册限流记录
|
||||
expired_cookies = [
|
||||
cookie for cookie, expire_time in self._registration_cookies.items()
|
||||
if expire_time <= current_time
|
||||
]
|
||||
|
||||
for cookie in expired_cookies:
|
||||
del self._registration_cookies[cookie]
|
||||
logger.debug(f"Cookie {cookie[:8]}... 限流记录已过期,自动清理")
|
||||
|
||||
if expired_aliases or expired_cookies:
|
||||
logger.info(f"清理完成:{len(expired_aliases)} 个用户名,{len(expired_cookies)} 个 Cookie")
|
||||
|
||||
def _start_cleanup_thread(self) -> None:
|
||||
"""启动定期清理线程"""
|
||||
def cleanup_loop():
|
||||
while True:
|
||||
try:
|
||||
time.sleep(60) # 每 60 秒清理一次
|
||||
self._cleanup_expired_records()
|
||||
except Exception as e:
|
||||
logger.error(f"清理线程异常: {e}")
|
||||
|
||||
thread = threading.Thread(target=cleanup_loop, daemon=True)
|
||||
thread.start()
|
||||
logger.info("注册管理器清理线程已启动")
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
"""获取当前状态统计"""
|
||||
with self._lock:
|
||||
return {
|
||||
'reserved_aliases_count': len(self._reserved_aliases),
|
||||
'rate_limited_cookies_count': len(self._registration_cookies),
|
||||
'reserved_aliases': list(self._reserved_aliases.keys()),
|
||||
}
|
||||
|
||||
|
||||
# 全局单例
|
||||
registration_manager = RegistrationManager()
|
||||
@@ -0,0 +1,382 @@
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from filelock import FileLock
|
||||
from sqlalchemy.orm import Session
|
||||
from croniter import croniter
|
||||
|
||||
from backend.config import settings
|
||||
from backend.models import get_db, User, CheckInTask
|
||||
from backend.services.check_in_service import CheckInService
|
||||
from backend.services.admin_service import AdminService
|
||||
from backend.workers.email_notifier import send_expiration_notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 全局调度器实例
|
||||
scheduler = None
|
||||
scheduler_lock = None
|
||||
|
||||
|
||||
def load_scheduled_tasks(db: Session, scheduler_instance):
|
||||
"""
|
||||
从数据库加载所有启用的定时任务并添加到 APScheduler
|
||||
|
||||
只加载满足以下条件的任务:
|
||||
- is_active = True
|
||||
- cron_expression IS NOT NULL
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
scheduler_instance: APScheduler BackgroundScheduler 实例
|
||||
|
||||
Returns:
|
||||
包含统计信息的字典
|
||||
"""
|
||||
logger.info("正在从数据库加载定时任务...")
|
||||
|
||||
# 移除所有现有的动态任务(保留系统任务)
|
||||
for job in scheduler_instance.get_jobs():
|
||||
if job.id.startswith('task_'):
|
||||
scheduler_instance.remove_job(job.id)
|
||||
|
||||
# 查询所有启用且有 cron 表达式的任务
|
||||
tasks = db.query(CheckInTask).filter(
|
||||
CheckInTask.is_active == True,
|
||||
CheckInTask.cron_expression.isnot(None)
|
||||
).all()
|
||||
|
||||
loaded_count = 0
|
||||
skipped_count = 0
|
||||
error_count = 0
|
||||
|
||||
for task in tasks:
|
||||
try:
|
||||
# 验证 cron 表达式
|
||||
cron_str = str(task.cron_expression) if task.cron_expression else None
|
||||
if not cron_str or not croniter.is_valid(cron_str):
|
||||
logger.warning(f"跳过任务 {task.id}: 无效的 cron 表达式 '{task.cron_expression}'")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# 创建任务 ID
|
||||
job_id = f"task_{task.id}"
|
||||
|
||||
# 检查任务是否已存在
|
||||
if scheduler_instance.get_job(job_id):
|
||||
logger.debug(f"任务 {task.id} 已存在,跳过")
|
||||
continue
|
||||
|
||||
# 添加任务到调度器
|
||||
scheduler_instance.add_job(
|
||||
func=scheduled_check_in_task,
|
||||
trigger=CronTrigger.from_crontab(cron_str),
|
||||
id=job_id,
|
||||
name=f"CheckIn-Task-{task.id}",
|
||||
args=[task.id],
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
logger.info(f"✅ 加载任务 {task.id}: {task.name} (Cron: {task.cron_expression})")
|
||||
loaded_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 加载任务 {task.id} 时出错: {str(e)}")
|
||||
error_count += 1
|
||||
|
||||
result = {
|
||||
"loaded": loaded_count,
|
||||
"skipped": skipped_count,
|
||||
"errors": error_count,
|
||||
"total": len(tasks)
|
||||
}
|
||||
|
||||
logger.info(f"任务加载完成: {result}")
|
||||
return result
|
||||
|
||||
|
||||
def scheduled_check_in_task(task_id: int):
|
||||
"""
|
||||
执行指定任务的定时打卡
|
||||
|
||||
这是由 APScheduler 在 cron 触发器触发时调用的函数
|
||||
使用与批量打卡相同的逻辑
|
||||
"""
|
||||
from backend.models.database import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
|
||||
if not task:
|
||||
logger.error(f"任务 {task_id} 不存在")
|
||||
return
|
||||
|
||||
if not task.is_scheduled_enabled:
|
||||
logger.info(f"任务 {task_id} 未启用定时打卡 (is_active={task.is_active}, cron={task.cron_expression})")
|
||||
return
|
||||
|
||||
logger.info(f"🤖 执行定时打卡任务 {task_id}")
|
||||
|
||||
# 开始异步打卡
|
||||
CheckInService.start_async_check_in(task, "scheduled", db)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"执行定时打卡任务 {task_id} 时出错: {str(e)}", exc_info=True)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def cleanup_expired_pending_users():
|
||||
"""定时清理过期未审批用户(24小时未审批)"""
|
||||
logger.info("Scheduler: 正在清理过期未审批用户...")
|
||||
|
||||
try:
|
||||
# 创建数据库会话
|
||||
db = next(get_db())
|
||||
|
||||
try:
|
||||
count = AdminService.delete_expired_pending_users(db)
|
||||
logger.info(f"Scheduler: 已删除 {count} 个过期未审批用户")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler: 清理过期用户任务发生错误: {e}", exc_info=True)
|
||||
|
||||
|
||||
def check_token_expiration():
|
||||
"""
|
||||
检查 Token 是否即将过期,并发送邮件提醒
|
||||
|
||||
检查所有用户的 Token,如果在 30 分钟内过期,发送提醒邮件
|
||||
注意:现在需要检查用户的任务,因为邮箱地址在任务中
|
||||
"""
|
||||
logger.info("Scheduler: 正在执行 Token 过期检查...")
|
||||
|
||||
try:
|
||||
# 创建数据库会话
|
||||
db = next(get_db())
|
||||
|
||||
try:
|
||||
# 获取所有用户
|
||||
users = db.query(User).all()
|
||||
current_timestamp = int(datetime.now().timestamp())
|
||||
|
||||
notified_count = 0
|
||||
|
||||
for user in users:
|
||||
if not user.jwt_exp or user.jwt_exp == "0":
|
||||
continue
|
||||
|
||||
try:
|
||||
exp_timestamp = int(user.jwt_exp)
|
||||
|
||||
# 检查是否在 30 分钟内过期(0 < 剩余时间 < 1800秒)
|
||||
time_until_expiry = exp_timestamp - current_timestamp
|
||||
|
||||
if 0 < time_until_expiry < 1800: # 30分钟 = 1800秒
|
||||
# 使用用户账户的邮箱发送通知
|
||||
if user.email:
|
||||
logger.info(f"用户 {user.alias} 的 Token 即将过期,发送邮件提醒到 {user.email}...")
|
||||
send_expiration_notification(user.email, user.jwt_exp)
|
||||
notified_count += 1
|
||||
|
||||
except ValueError:
|
||||
logger.warning(f"用户 {user.alias} 的 jwt_exp 格式不正确: {user.jwt_exp}")
|
||||
continue
|
||||
|
||||
logger.info(f"Scheduler: Token 过期检查完成,共发送 {notified_count} 封提醒邮件")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler: Token 过期检查任务发生错误: {e}", exc_info=True)
|
||||
|
||||
|
||||
def scheduled_check_in():
|
||||
"""
|
||||
定时打卡任务:每天定时为所有启用的任务执行打卡
|
||||
"""
|
||||
logger.info("Scheduler: 开始执行定时打卡任务...")
|
||||
|
||||
try:
|
||||
# 创建数据库会话
|
||||
db = next(get_db())
|
||||
|
||||
try:
|
||||
result = CheckInService.scheduled_check_in_all_active_tasks(db)
|
||||
|
||||
logger.info(
|
||||
f"Scheduler: 定时打卡任务完成,"
|
||||
f"总计: {result['total']}, "
|
||||
f"成功: {result['success']}, "
|
||||
f"失败: {result['failure']}, "
|
||||
f"跳过: {result['skipped']}"
|
||||
)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler: 定时打卡任务发生错误: {e}", exc_info=True)
|
||||
|
||||
|
||||
def cleanup_old_sessions():
|
||||
"""
|
||||
清理旧的会话文件
|
||||
|
||||
删除超过指定时间的会话文件
|
||||
"""
|
||||
logger.info("Scheduler: 开始清理旧会话文件...")
|
||||
|
||||
try:
|
||||
session_dir = settings.SESSION_DIR
|
||||
|
||||
if not session_dir.exists():
|
||||
logger.info("Scheduler: 会话目录不存在,跳过清理")
|
||||
return
|
||||
|
||||
current_time = time.time()
|
||||
cleanup_threshold = settings.SESSION_CLEANUP_HOURS * 3600 # 转换为秒
|
||||
|
||||
deleted_count = 0
|
||||
|
||||
for file_path in session_dir.glob("*.json"):
|
||||
try:
|
||||
# 获取文件修改时间
|
||||
file_mtime = file_path.stat().st_mtime
|
||||
file_age = current_time - file_mtime
|
||||
|
||||
# 如果文件超过阈值,删除它
|
||||
if file_age > cleanup_threshold:
|
||||
# 同时删除对应的锁文件
|
||||
lock_file = session_dir / f"{file_path.stem}.json.lock"
|
||||
|
||||
file_path.unlink()
|
||||
if lock_file.exists():
|
||||
lock_file.unlink()
|
||||
|
||||
deleted_count += 1
|
||||
logger.debug(f"删除旧会话文件: {file_path.name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"删除会话文件 {file_path.name} 时出错: {e}")
|
||||
|
||||
logger.info(f"Scheduler: 会话文件清理完成,共删除 {deleted_count} 个文件")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler: 清理会话文件任务发生错误: {e}", exc_info=True)
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
"""
|
||||
启动调度器
|
||||
|
||||
使用文件锁确保在多进程部署时只有一个调度器运行
|
||||
"""
|
||||
global scheduler, scheduler_lock
|
||||
|
||||
# 创建调度器锁文件
|
||||
lock_file = settings.BASE_DIR / "scheduler.lock"
|
||||
scheduler_lock = FileLock(lock_file, timeout=1)
|
||||
|
||||
try:
|
||||
# 尝试获取锁
|
||||
scheduler_lock.acquire(blocking=False)
|
||||
|
||||
logger.info("成功获取调度器锁,启动调度器...")
|
||||
|
||||
# 创建后台调度器
|
||||
scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
|
||||
|
||||
# 添加定时打卡任务(每天指定时间)
|
||||
scheduler.add_job(
|
||||
scheduled_check_in,
|
||||
trigger=CronTrigger(
|
||||
hour=settings.CHECKIN_SCHEDULE_HOUR,
|
||||
minute=settings.CHECKIN_SCHEDULE_MINUTE
|
||||
),
|
||||
id="scheduled_check_in",
|
||||
name="定时打卡任务",
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info(
|
||||
f"已添加定时打卡任务: 每天 {settings.CHECKIN_SCHEDULE_HOUR:02d}:{settings.CHECKIN_SCHEDULE_MINUTE:02d}"
|
||||
)
|
||||
|
||||
# 添加 Token 过期检查任务(每隔指定分钟)
|
||||
scheduler.add_job(
|
||||
check_token_expiration,
|
||||
trigger="interval",
|
||||
minutes=settings.TOKEN_CHECK_INTERVAL_MINUTES,
|
||||
id="check_token_expiration",
|
||||
name="Token 过期检查任务",
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info(
|
||||
f"已添加 Token 过期检查任务: 每 {settings.TOKEN_CHECK_INTERVAL_MINUTES} 分钟"
|
||||
)
|
||||
|
||||
# 添加会话文件清理任务(每隔指定小时)
|
||||
scheduler.add_job(
|
||||
cleanup_old_sessions,
|
||||
trigger="interval",
|
||||
hours=settings.SESSION_CLEANUP_INTERVAL_HOURS,
|
||||
id="cleanup_old_sessions",
|
||||
name="清理旧会话文件任务",
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info(
|
||||
f"已添加会话清理任务: 每 {settings.SESSION_CLEANUP_INTERVAL_HOURS} 小时"
|
||||
)
|
||||
|
||||
# 添加清理过期未审批用户任务(每小时执行一次)
|
||||
scheduler.add_job(
|
||||
cleanup_expired_pending_users,
|
||||
trigger="interval",
|
||||
hours=1,
|
||||
id="cleanup_expired_pending_users",
|
||||
name="清理过期未审批用户任务",
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info("已添加清理过期未审批用户任务: 每 1 小时")
|
||||
|
||||
# 新增:从数据库加载动态任务
|
||||
db = next(get_db())
|
||||
try:
|
||||
load_scheduled_tasks(db, scheduler)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# 启动调度器
|
||||
scheduler.start()
|
||||
logger.info("调度器已启动")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"无法获取调度器锁或启动失败: {e}")
|
||||
logger.info("可能其他进程已经在运行调度器,跳过启动")
|
||||
scheduler_lock = None
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
"""
|
||||
停止调度器并释放锁
|
||||
"""
|
||||
global scheduler, scheduler_lock
|
||||
|
||||
if scheduler:
|
||||
logger.info("正在停止调度器...")
|
||||
scheduler.shutdown()
|
||||
logger.info("调度器已停止")
|
||||
|
||||
if scheduler_lock:
|
||||
try:
|
||||
scheduler_lock.release()
|
||||
logger.info("已释放调度器锁")
|
||||
except Exception as e:
|
||||
logger.warning(f"释放调度器锁时出错: {e}")
|
||||
@@ -0,0 +1,386 @@
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
import json
|
||||
|
||||
from backend.models import User, CheckInTask, CheckInRecord
|
||||
from backend.schemas.task import TaskCreate, TaskUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskService:
|
||||
"""打卡任务服务"""
|
||||
|
||||
@staticmethod
|
||||
def create_task(user_id: int, task_data: TaskCreate, db: Session) -> CheckInTask:
|
||||
"""
|
||||
创建打卡任务
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
task_data: 任务数据
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
创建的任务对象
|
||||
"""
|
||||
import json
|
||||
|
||||
# 1. 检查用户是否存在
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise ValueError(f"用户 ID {user_id} 不存在")
|
||||
|
||||
# 2. 从 payload_config 中提取 ThreadId 用于唯一性校验
|
||||
try:
|
||||
payload = json.loads(task_data.payload_config)
|
||||
thread_id = payload.get('ThreadId')
|
||||
if not thread_id:
|
||||
raise ValueError("payload_config 中缺少 ThreadId")
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError("payload_config 格式错误,必须是有效的 JSON")
|
||||
|
||||
# 3. 验证唯一性:同一用户在同一个接龙中不能有重复的任务
|
||||
# 查询用户的所有任务,检查是否已经有同一个 ThreadId
|
||||
existing_tasks = db.query(CheckInTask).filter(
|
||||
CheckInTask.user_id == user_id
|
||||
).all()
|
||||
|
||||
for task in existing_tasks:
|
||||
try:
|
||||
existing_payload = json.loads(task.payload_config)
|
||||
if existing_payload.get('ThreadId') == thread_id:
|
||||
logger.warning(f"⚠️ 任务创建冲突 - User: {user.alias}({user_id}), ThreadId: {thread_id}")
|
||||
raise ValueError(
|
||||
f"该接龙中已存在任务。ThreadId: {thread_id}"
|
||||
)
|
||||
except (json.JSONDecodeError, AttributeError, TypeError):
|
||||
# 跳过无法解析的 payload_config
|
||||
logger.debug(f"跳过无法解析的任务配置 - Task ID: {task.id}")
|
||||
continue
|
||||
|
||||
# 4. 记录日志
|
||||
task_name = task_data.name or f"接龙任务 {thread_id}"
|
||||
logger.info(f"📝 用户 {user.alias}({user_id}) 正在创建任务: {task_name}")
|
||||
|
||||
# 5. 创建任务
|
||||
task = CheckInTask(
|
||||
user_id=user_id,
|
||||
payload_config=task_data.payload_config,
|
||||
name=task_data.name or task_name,
|
||||
is_active=task_data.is_active if task_data.is_active is not None else True
|
||||
)
|
||||
|
||||
try:
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
logger.info(f"✅ 任务创建成功 - ID: {task.id}, Name: {task.name}, ThreadId: {thread_id}")
|
||||
|
||||
# 如果任务启用且包含 cron_expression,立即添加到调度器
|
||||
if task.is_scheduled_enabled:
|
||||
TaskService._reload_scheduler_for_task(task, db)
|
||||
|
||||
return task
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"❌ 任务创建失败: {str(e)}")
|
||||
raise ValueError(f"任务创建失败: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def get_task(task_id: int, db: Session) -> Optional[CheckInTask]:
|
||||
"""
|
||||
获取任务详情
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
任务对象或 None
|
||||
"""
|
||||
return db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
|
||||
|
||||
@staticmethod
|
||||
def enrich_task_with_check_in_info(task: CheckInTask, db: Session) -> dict:
|
||||
"""
|
||||
为任务添加最后一次打卡信息和 ThreadId
|
||||
|
||||
Args:
|
||||
task: 任务对象
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
包含额外信息的任务字典
|
||||
"""
|
||||
# 获取最后一次打卡记录
|
||||
last_record = db.query(CheckInRecord).filter(
|
||||
CheckInRecord.task_id == task.id
|
||||
).order_by(desc(CheckInRecord.check_in_time)).first()
|
||||
|
||||
# 从 payload_config 提取 ThreadId
|
||||
thread_id = None
|
||||
try:
|
||||
payload = json.loads(str(task.payload_config))
|
||||
thread_id = payload.get('ThreadId')
|
||||
except (json.JSONDecodeError, AttributeError, TypeError):
|
||||
logger.debug(f"无法从任务 {task.id} 的 payload_config 中提取 ThreadId")
|
||||
pass
|
||||
|
||||
# 转换为字典并添加额外字段
|
||||
task_dict = {
|
||||
'id': task.id,
|
||||
'user_id': task.user_id,
|
||||
'payload_config': task.payload_config,
|
||||
'name': task.name,
|
||||
'is_active': task.is_active,
|
||||
'cron_expression': task.cron_expression,
|
||||
'is_scheduled_enabled': task.is_scheduled_enabled,
|
||||
'created_at': task.created_at,
|
||||
'updated_at': task.updated_at,
|
||||
'thread_id': thread_id,
|
||||
'last_check_in_time': last_record.check_in_time if last_record else None,
|
||||
'last_check_in_status': last_record.status if last_record else None,
|
||||
}
|
||||
|
||||
return task_dict
|
||||
|
||||
@staticmethod
|
||||
def get_user_tasks(user_id: int, db: Session, include_inactive: bool = True) -> List[CheckInTask]:
|
||||
"""
|
||||
获取用户的所有任务
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
db: 数据库会话
|
||||
include_inactive: 是否包含未启用的任务
|
||||
|
||||
Returns:
|
||||
任务列表
|
||||
"""
|
||||
query = db.query(CheckInTask).filter(CheckInTask.user_id == user_id)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(CheckInTask.is_active == True)
|
||||
|
||||
return query.order_by(desc(CheckInTask.created_at)).all()
|
||||
|
||||
@staticmethod
|
||||
def get_all_active_tasks(db: Session) -> List[CheckInTask]:
|
||||
"""
|
||||
获取所有启用的任务(用于定时打卡)
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
启用的任务列表
|
||||
"""
|
||||
return db.query(CheckInTask).filter(CheckInTask.is_active == True).all()
|
||||
|
||||
@staticmethod
|
||||
def update_task(task_id: int, task_data: TaskUpdate, db: Session) -> Optional[CheckInTask]:
|
||||
"""
|
||||
更新任务
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
task_data: 更新数据
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
更新后的任务对象或 None
|
||||
"""
|
||||
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
|
||||
|
||||
if not task:
|
||||
return None
|
||||
|
||||
# 更新字段
|
||||
update_data = task_data.model_dump(exclude_unset=True)
|
||||
|
||||
# 检查是否更新了 cron_expression 或 is_active
|
||||
cron_changed = 'cron_expression' in update_data
|
||||
active_changed = 'is_active' in update_data
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(task, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
logger.info(f"任务 {task_id} 已更新")
|
||||
|
||||
# 如果 cron_expression 或 is_active 发生变化,重新加载调度器
|
||||
if cron_changed or active_changed:
|
||||
TaskService._reload_scheduler_for_task(task, db)
|
||||
|
||||
return task
|
||||
|
||||
@staticmethod
|
||||
def delete_task(task_id: int, db: Session) -> bool:
|
||||
"""
|
||||
删除任务
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
"""
|
||||
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
|
||||
|
||||
if not task:
|
||||
return False
|
||||
|
||||
db.delete(task)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"任务 {task_id} 已删除")
|
||||
|
||||
# 从调度器中移除该任务
|
||||
TaskService._remove_task_from_scheduler(task_id)
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def toggle_task(task_id: int, db: Session) -> Optional[CheckInTask]:
|
||||
"""
|
||||
切换任务的启用状态
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
更新后的任务对象或 None
|
||||
"""
|
||||
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
|
||||
|
||||
if not task:
|
||||
return None
|
||||
|
||||
task.is_active = not task.is_active
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
logger.info(f"任务 {task_id} 状态已切换为: {'启用' if task.is_active else '禁用'}")
|
||||
|
||||
# 重新加载调度器
|
||||
TaskService._reload_scheduler_for_task(task, db)
|
||||
|
||||
return task
|
||||
|
||||
@staticmethod
|
||||
def get_task_records(task_id: int, db: Session, limit: int = 50) -> List[CheckInRecord]:
|
||||
"""
|
||||
获取任务的打卡记录
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
db: 数据库会话
|
||||
limit: 返回记录数量限制
|
||||
|
||||
Returns:
|
||||
打卡记录列表
|
||||
"""
|
||||
return (
|
||||
db.query(CheckInRecord)
|
||||
.filter(CheckInRecord.task_id == task_id)
|
||||
.order_by(desc(CheckInRecord.check_in_time))
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def verify_task_ownership(task_id: int, user_id: int, db: Session) -> bool:
|
||||
"""
|
||||
验证任务是否属于指定用户
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
user_id: 用户 ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
是否属于该用户
|
||||
"""
|
||||
task = db.query(CheckInTask).filter(
|
||||
CheckInTask.id == task_id,
|
||||
CheckInTask.user_id == user_id
|
||||
).first()
|
||||
|
||||
return task is not None
|
||||
|
||||
@staticmethod
|
||||
def _reload_scheduler_for_task(task: CheckInTask, db: Session):
|
||||
"""
|
||||
重新加载指定任务到调度器
|
||||
|
||||
Args:
|
||||
task: 任务对象
|
||||
db: 数据库会话
|
||||
"""
|
||||
try:
|
||||
from backend.services.scheduler_service import scheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from croniter import croniter
|
||||
|
||||
if not scheduler:
|
||||
logger.warning(f"调度器未启动,无法加载任务 {task.id}")
|
||||
return
|
||||
|
||||
job_id = f"task_{task.id}"
|
||||
|
||||
# 先移除旧的任务(如果存在)
|
||||
if scheduler.get_job(job_id):
|
||||
scheduler.remove_job(job_id)
|
||||
logger.debug(f"从调度器移除旧任务: {job_id}")
|
||||
|
||||
# 如果任务启用且有有效的 cron 表达式,添加新任务
|
||||
if task.is_scheduled_enabled:
|
||||
cron_str = str(task.cron_expression)
|
||||
if croniter.is_valid(cron_str):
|
||||
from backend.services.scheduler_service import scheduled_check_in_task
|
||||
|
||||
scheduler.add_job(
|
||||
func=scheduled_check_in_task,
|
||||
trigger=CronTrigger.from_crontab(cron_str),
|
||||
id=job_id,
|
||||
name=f"CheckIn-Task-{task.id}",
|
||||
args=[task.id],
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info(f"✅ 任务 {task.id} 已添加到调度器: {cron_str}")
|
||||
else:
|
||||
logger.warning(f"任务 {task.id} 的 cron 表达式无效: {cron_str}")
|
||||
else:
|
||||
logger.info(f"任务 {task.id} 未启用或无 cron 表达式,已从调度器移除")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"重新加载任务 {task.id} 到调度器失败: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def _remove_task_from_scheduler(task_id: int):
|
||||
"""
|
||||
从调度器中移除指定任务
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
"""
|
||||
try:
|
||||
from backend.services.scheduler_service import scheduler
|
||||
|
||||
if not scheduler:
|
||||
return
|
||||
|
||||
job_id = f"task_{task_id}"
|
||||
if scheduler.get_job(job_id):
|
||||
scheduler.remove_job(job_id)
|
||||
logger.info(f"✅ 任务 {task_id} 已从调度器移除")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"从调度器移除任务 {task_id} 失败: {str(e)}")
|
||||
@@ -0,0 +1,568 @@
|
||||
import logging
|
||||
import json
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from backend.models import TaskTemplate, CheckInTask
|
||||
from backend.schemas.template import TemplateCreate, TemplateUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TemplateService:
|
||||
"""模板服务"""
|
||||
|
||||
@staticmethod
|
||||
def _deep_merge(parent: Any, child: Any) -> Any:
|
||||
"""
|
||||
深度合并配置,子配置会覆盖父配置
|
||||
|
||||
Args:
|
||||
parent: 父配置
|
||||
child: 子配置
|
||||
|
||||
Returns:
|
||||
合并后的配置
|
||||
"""
|
||||
# 如果子配置不是字典或数组,直接返回子配置(覆盖)
|
||||
if not isinstance(child, (dict, list)):
|
||||
return child
|
||||
|
||||
# 如果父配置不是同类型,直接返回子配置
|
||||
if type(parent) != type(child):
|
||||
return child
|
||||
|
||||
# 处理字典合并
|
||||
if isinstance(child, dict):
|
||||
result = dict(parent) # 先复制父配置
|
||||
for key, value in child.items():
|
||||
if key in parent:
|
||||
# 递归合并
|
||||
result[key] = TemplateService._deep_merge(parent[key], value)
|
||||
else:
|
||||
# 新字段,直接添加
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
# 处理数组合并
|
||||
if isinstance(child, list):
|
||||
# 数组按索引位置合并
|
||||
result = []
|
||||
max_len = max(len(parent), len(child))
|
||||
for i in range(max_len):
|
||||
if i < len(child):
|
||||
if i < len(parent):
|
||||
# 两边都有,递归合并
|
||||
result.append(TemplateService._deep_merge(parent[i], child[i]))
|
||||
else:
|
||||
# 只有子配置有,直接添加
|
||||
result.append(child[i])
|
||||
else:
|
||||
# 只有父配置有,保留父配置
|
||||
result.append(parent[i])
|
||||
return result
|
||||
|
||||
return child
|
||||
|
||||
@staticmethod
|
||||
def merge_parent_config(template: TaskTemplate, db: Session) -> Dict[str, Any]:
|
||||
"""
|
||||
合并父模板的字段配置到当前模板
|
||||
|
||||
Args:
|
||||
template: 当前模板对象
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
合并后的完整字段配置
|
||||
"""
|
||||
# 解析当前模板配置
|
||||
current_config = json.loads(str(template.field_config))
|
||||
|
||||
# 如果没有父模板,直接返回当前配置
|
||||
if template.parent_id is None:
|
||||
return current_config
|
||||
|
||||
# 获取父模板
|
||||
parent = db.query(TaskTemplate).filter(TaskTemplate.id == template.parent_id).first()
|
||||
if not parent:
|
||||
logger.warning(f"模板 {template.id} 的父模板 {template.parent_id} 不存在")
|
||||
return current_config
|
||||
|
||||
# 递归获取父模板的完整配置(支持多层继承)
|
||||
parent_config = TemplateService.merge_parent_config(parent, db)
|
||||
|
||||
# 深度合并配置:子模板的配置会覆盖父模板的同名字段
|
||||
merged = TemplateService._deep_merge(parent_config, current_config)
|
||||
|
||||
return merged
|
||||
|
||||
@staticmethod
|
||||
def create_template(template_data: TemplateCreate, db: Session) -> TaskTemplate:
|
||||
"""
|
||||
创建新模板
|
||||
|
||||
Args:
|
||||
template_data: 模板创建数据
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
创建的模板对象
|
||||
"""
|
||||
try:
|
||||
# 验证 field_config 是有效的 JSON
|
||||
if isinstance(template_data.field_config, str):
|
||||
json.loads(template_data.field_config)
|
||||
|
||||
template = TaskTemplate(
|
||||
name=template_data.name,
|
||||
description=template_data.description,
|
||||
field_config=template_data.field_config,
|
||||
parent_id=template_data.parent_id,
|
||||
is_active=template_data.is_active,
|
||||
)
|
||||
db.add(template)
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
logger.info(f"创建模板成功: {template.name} (ID: {template.id})")
|
||||
return template
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"模板字段配置 JSON 格式错误: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"字段配置 JSON 格式错误: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"创建模板失败: {str(e)}")
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"创建模板失败: {str(e)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_template(template_id: int, db: Session) -> Optional[TaskTemplate]:
|
||||
"""
|
||||
获取单个模板
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
模板对象或 None
|
||||
"""
|
||||
return db.query(TaskTemplate).filter(TaskTemplate.id == template_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_all_templates(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
is_active: Optional[bool] = None
|
||||
) -> List[TaskTemplate]:
|
||||
"""
|
||||
获取所有模板列表
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
skip: 跳过记录数
|
||||
limit: 限制记录数
|
||||
is_active: 过滤启用状态
|
||||
|
||||
Returns:
|
||||
模板列表
|
||||
"""
|
||||
query = db.query(TaskTemplate)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(TaskTemplate.is_active == is_active)
|
||||
|
||||
return query.order_by(TaskTemplate.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
@staticmethod
|
||||
def update_template(
|
||||
template_id: int,
|
||||
template_data: TemplateUpdate,
|
||||
db: Session
|
||||
) -> TaskTemplate:
|
||||
"""
|
||||
更新模板
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
template_data: 更新数据
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
更新后的模板对象
|
||||
"""
|
||||
template = TemplateService.get_template(template_id, db)
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="模板不存在"
|
||||
)
|
||||
|
||||
try:
|
||||
# 更新字段
|
||||
update_data = template_data.model_dump(exclude_unset=True)
|
||||
|
||||
# 验证 field_config 如果有更新
|
||||
if 'field_config' in update_data and update_data['field_config']:
|
||||
json.loads(update_data['field_config'])
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(template, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
logger.info(f"更新模板成功: {template.name} (ID: {template.id})")
|
||||
return template
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"模板字段配置 JSON 格式错误: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"字段配置 JSON 格式错误: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"更新模板失败: {str(e)}")
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"更新模板失败: {str(e)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_template(template_id: int, db: Session) -> bool:
|
||||
"""
|
||||
删除模板
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
"""
|
||||
template = TemplateService.get_template(template_id, db)
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="模板不存在"
|
||||
)
|
||||
|
||||
try:
|
||||
db.delete(template)
|
||||
db.commit()
|
||||
logger.info(f"删除模板成功: {template.name} (ID: {template_id})")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"删除模板失败: {str(e)}")
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"删除模板失败: {str(e)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_field_config(obj: Any) -> bool:
|
||||
"""判断是否为字段配置对象"""
|
||||
return isinstance(obj, dict) and 'display_name' in obj
|
||||
|
||||
@staticmethod
|
||||
def _is_object_field(obj: Any) -> bool:
|
||||
"""判断是否为对象字段(包含多个子字段配置)"""
|
||||
if not isinstance(obj, dict):
|
||||
return False
|
||||
if 'display_name' in obj:
|
||||
return False
|
||||
# 检查所有值是否都是字段配置对象
|
||||
return all(
|
||||
TemplateService._is_field_config(v)
|
||||
for v in obj.values()
|
||||
if isinstance(v, dict)
|
||||
) and len(obj) > 0
|
||||
|
||||
@staticmethod
|
||||
def _process_field_value(key: str, config: Any, field_values: Dict[str, Any]) -> Any:
|
||||
"""
|
||||
递归处理字段配置,生成 payload 值
|
||||
|
||||
Args:
|
||||
key: 字段名
|
||||
config: 字段配置
|
||||
field_values: 用户输入值
|
||||
|
||||
Returns:
|
||||
处理后的值
|
||||
"""
|
||||
# 1. 普通字段配置
|
||||
if TemplateService._is_field_config(config):
|
||||
if config.get('hidden', False):
|
||||
value = config.get('default_value', '')
|
||||
else:
|
||||
value = field_values.get(key, config.get('default_value', ''))
|
||||
|
||||
value_type = config.get('value_type', 'string')
|
||||
return TemplateService._validate_and_convert_value(value, value_type, key)
|
||||
|
||||
# 2. 数组字段
|
||||
if isinstance(config, list):
|
||||
result = []
|
||||
for item_config in config:
|
||||
# 检查数组元素是否是字段配置对象
|
||||
if TemplateService._is_field_config(item_config):
|
||||
# 数组元素是字段配置对象,需要序列化为 JSON 字符串
|
||||
value = item_config.get('default_value', '')
|
||||
value_type = item_config.get('value_type', 'string')
|
||||
# 将对象序列化为 JSON 字符串
|
||||
if value_type == 'json':
|
||||
if isinstance(value, str):
|
||||
# 如果是字符串,验证 JSON 格式
|
||||
try:
|
||||
json.loads(value)
|
||||
except json.JSONDecodeError as e:
|
||||
# 提供更详细的错误信息
|
||||
error_detail = f"数组元素的默认值不是有效的 JSON: {value}\n"
|
||||
error_detail += f"JSON 解析错误: {str(e)}\n"
|
||||
error_detail += "常见问题: 数字不能有前导零(如 00.00 应改为 0.0)"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_detail
|
||||
)
|
||||
result.append(value)
|
||||
else:
|
||||
# 如果是对象,序列化为 JSON 字符串
|
||||
result.append(json.dumps(value, ensure_ascii=False))
|
||||
else:
|
||||
result.append(TemplateService._validate_and_convert_value(value, value_type, key))
|
||||
elif isinstance(item_config, dict):
|
||||
# 数组元素是普通对象,递归处理
|
||||
item = {}
|
||||
for item_key, item_value in item_config.items():
|
||||
# 保持键名原样
|
||||
item[item_key] = TemplateService._process_field_value(
|
||||
item_key, item_value, field_values
|
||||
)
|
||||
result.append(item)
|
||||
else:
|
||||
result.append(item_config)
|
||||
return result
|
||||
|
||||
# 3. 对象字段(包含多个子字段)
|
||||
if TemplateService._is_object_field(config):
|
||||
result = {}
|
||||
for sub_key, sub_config in config.items():
|
||||
# 保持键名原样
|
||||
result[sub_key] = TemplateService._process_field_value(
|
||||
sub_key, sub_config, field_values
|
||||
)
|
||||
return result
|
||||
|
||||
# 4. 其他情况,返回原值
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def generate_preview_payload(template: TaskTemplate, db: Session) -> Dict[str, Any]:
|
||||
"""
|
||||
生成模板预览 payload(使用默认值)
|
||||
完全根据模板配置动态生成
|
||||
|
||||
新架构:配置完全映射到 Payload 结构
|
||||
|
||||
Args:
|
||||
template: 模板对象
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
预览 payload
|
||||
"""
|
||||
try:
|
||||
# 合并父模板配置
|
||||
field_config = TemplateService.merge_parent_config(template, db)
|
||||
|
||||
# 初始化 payload,只包含 ThreadId(唯一必需,不在模板中配置)
|
||||
payload = {
|
||||
"ThreadId": "<接龙项目ID>"
|
||||
}
|
||||
|
||||
# 递归处理所有字段,保持键名原样
|
||||
for key, config in field_config.items():
|
||||
payload[key] = TemplateService._process_field_value(key, config, {})
|
||||
|
||||
return payload
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"解析模板配置失败: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"解析模板配置失败: {str(e)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def assemble_payload_from_template(
|
||||
template: TaskTemplate,
|
||||
thread_id: str,
|
||||
field_values: Dict[str, Any],
|
||||
db: Session
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
根据模板和用户输入组装完整的 payload
|
||||
完全根据模板配置动态生成
|
||||
|
||||
新架构:配置完全映射到 Payload 结构
|
||||
|
||||
Args:
|
||||
template: 模板对象
|
||||
thread_id: 接龙项目 ID
|
||||
field_values: 用户填写的字段值
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
完整的 payload
|
||||
"""
|
||||
try:
|
||||
# 合并父模板配置
|
||||
field_config = TemplateService.merge_parent_config(template, db)
|
||||
|
||||
# 初始化 payload,只包含 ThreadId(唯一必需)
|
||||
payload = {
|
||||
"ThreadId": thread_id
|
||||
}
|
||||
|
||||
# 递归处理所有字段,保持键名原样
|
||||
for key, config in field_config.items():
|
||||
payload[key] = TemplateService._process_field_value(key, config, field_values)
|
||||
|
||||
return payload
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"解析模板配置失败: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"解析模板配置失败"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"组装 payload 失败: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"组装 payload 失败: {str(e)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_and_convert_value(value: Any, value_type: str, field_name: str) -> Any:
|
||||
"""
|
||||
验证并转换字段值类型
|
||||
|
||||
Args:
|
||||
value: 字段值
|
||||
value_type: 期望的类型 (string, int, double, bool, json)
|
||||
field_name: 字段名(用于错误提示)
|
||||
|
||||
Returns:
|
||||
转换后的值
|
||||
"""
|
||||
try:
|
||||
if value_type == 'int':
|
||||
return int(value) if value != '' else 0
|
||||
elif value_type == 'double':
|
||||
return float(value) if value != '' else 0.0
|
||||
elif value_type == 'bool':
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.lower() in ('true', '1', 'yes')
|
||||
return bool(value)
|
||||
elif value_type == 'json':
|
||||
# JSON 类型:如果是字符串,尝试解析后再序列化;如果是对象,直接序列化
|
||||
if isinstance(value, str):
|
||||
# 验证是否为有效 JSON
|
||||
json.loads(value)
|
||||
return value
|
||||
else:
|
||||
# 将对象序列化为 JSON 字符串
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
else: # string
|
||||
return str(value)
|
||||
except (ValueError, TypeError, json.JSONDecodeError) as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"字段 '{field_name}' 类型错误:期望 {value_type},实际值为 '{value}',错误: {str(e)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_task_from_template(
|
||||
template_id: int,
|
||||
thread_id: str,
|
||||
field_values: Dict[str, Any],
|
||||
user_id: int,
|
||||
task_name: Optional[str],
|
||||
db: Session
|
||||
) -> CheckInTask:
|
||||
"""
|
||||
从模板创建打卡任务
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
thread_id: 接龙项目 ID
|
||||
field_values: 用户填写的字段值
|
||||
user_id: 用户 ID
|
||||
task_name: 任务名称(可选)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
创建的任务对象
|
||||
"""
|
||||
# 获取模板
|
||||
template = TemplateService.get_template(template_id, db)
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="模板不存在"
|
||||
)
|
||||
|
||||
# 检查模板是否启用
|
||||
if template.is_active is not True:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="该模板未启用,无法创建任务"
|
||||
)
|
||||
|
||||
# 组装 payload
|
||||
payload = TemplateService.assemble_payload_from_template(
|
||||
template, thread_id, field_values, db
|
||||
)
|
||||
|
||||
# 生成任务名称
|
||||
if not task_name:
|
||||
signature = payload.get('Signature', 'Unknown')
|
||||
task_name = f"{template.name} - {signature}"
|
||||
|
||||
# 创建任务(只存储 payload_config,不再需要 thread_id 和 email)
|
||||
try:
|
||||
task = CheckInTask(
|
||||
user_id=user_id,
|
||||
payload_config=json.dumps(payload, ensure_ascii=False),
|
||||
name=task_name,
|
||||
is_active=True
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
logger.info(f"从模板创建任务成功: {task.name} (ID: {task.id}, 模板: {template.name}, ThreadId: {thread_id})")
|
||||
return task
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"从模板创建任务失败: {str(e)}")
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"创建任务失败: {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,289 @@
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
|
||||
from backend.models import User
|
||||
from backend.schemas.user import UserCreate, UserUpdate, UserUpdateProfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserService:
|
||||
"""用户服务"""
|
||||
|
||||
@staticmethod
|
||||
def create_user(user_data: UserCreate, db: Session) -> User:
|
||||
"""
|
||||
创建用户(管理员手动创建)
|
||||
|
||||
Args:
|
||||
user_data: 用户创建数据(只需要 alias 和 role)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
创建的用户对象
|
||||
"""
|
||||
# 检查 alias 是否已存在
|
||||
existing_alias = db.query(User).filter(User.alias == user_data.alias).first()
|
||||
if existing_alias:
|
||||
raise ValueError(f"用户别名 {user_data.alias} 已存在")
|
||||
|
||||
# 创建用户(管理员创建的用户没有 jwt_sub,需要后续扫码绑定)
|
||||
user = User(
|
||||
jwt_sub="", # 空字符串表示未绑定 QQ
|
||||
alias=user_data.alias,
|
||||
role=user_data.role or "user",
|
||||
is_approved=True, # 管理员创建的用户默认已审批
|
||||
jwt_exp="0",
|
||||
authorization=None,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info(f"管理员创建用户成功: {user.alias} (ID: {user.id}, 角色: {user.role})")
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_id(user_id: int, db: Session) -> Optional[User]:
|
||||
"""
|
||||
根据 ID 获取用户
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
用户对象或 None
|
||||
"""
|
||||
return db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_alias(alias: str, db: Session) -> Optional[User]:
|
||||
"""
|
||||
根据 alias 获取用户
|
||||
|
||||
Args:
|
||||
alias: 用户别名
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
用户对象或 None
|
||||
"""
|
||||
return db.query(User).filter(User.alias == alias).first()
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_jwt_sub(jwt_sub: str, db: Session) -> Optional[User]:
|
||||
"""
|
||||
根据 jwt_sub 获取用户
|
||||
|
||||
Args:
|
||||
jwt_sub: QQ 用户标识
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
用户对象或 None
|
||||
"""
|
||||
return db.query(User).filter(User.jwt_sub == jwt_sub).first()
|
||||
|
||||
@staticmethod
|
||||
def get_all_users(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
search: Optional[str] = None,
|
||||
role: Optional[str] = None
|
||||
) -> List[User]:
|
||||
"""
|
||||
获取所有用户
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
skip: 跳过记录数
|
||||
limit: 限制记录数
|
||||
search: 搜索关键词(alias 或 jwt_sub)
|
||||
role: 过滤角色(user/admin)
|
||||
|
||||
Returns:
|
||||
用户列表
|
||||
"""
|
||||
query = db.query(User)
|
||||
|
||||
# 搜索过滤
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
User.alias.ilike(f"%{search}%"),
|
||||
User.jwt_sub.ilike(f"%{search}%")
|
||||
)
|
||||
)
|
||||
|
||||
# 角色过滤
|
||||
if role:
|
||||
query = query.filter(User.role == role)
|
||||
|
||||
return query.offset(skip).limit(limit).all()
|
||||
|
||||
@staticmethod
|
||||
def update_user(user_id: int, user_data: UserUpdate, db: Session) -> User:
|
||||
"""
|
||||
更新用户信息(管理员操作)
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
user_data: 用户更新数据
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
更新后的用户对象
|
||||
"""
|
||||
from backend.services.auth_service import AuthService
|
||||
|
||||
user = UserService.get_user_by_id(user_id, db)
|
||||
if not user:
|
||||
raise ValueError(f"用户 ID {user_id} 不存在")
|
||||
|
||||
# 更新字段
|
||||
update_data = user_data.model_dump(exclude_unset=True)
|
||||
|
||||
# 如果更新 alias,检查是否重复
|
||||
if "alias" in update_data and update_data["alias"] != user.alias:
|
||||
existing_user = db.query(User).filter(User.alias == update_data["alias"]).first()
|
||||
if existing_user:
|
||||
raise ValueError(f"用户别名 {update_data['alias']} 已存在")
|
||||
|
||||
# 处理密码重置
|
||||
if update_data.get("reset_password"):
|
||||
user.password_hash = None
|
||||
logger.info(f"管理员重置用户 {user.alias} (ID: {user_id}) 的密码")
|
||||
|
||||
# 处理密码修改
|
||||
elif "password" in update_data and update_data["password"]:
|
||||
user.password_hash = AuthService.hash_password(update_data["password"])
|
||||
logger.info(f"管理员修改用户 {user.alias} (ID: {user_id}) 的密码")
|
||||
|
||||
# 更新其他字段(排除密码相关字段)
|
||||
excluded_fields = {"password", "reset_password"}
|
||||
for key, value in update_data.items():
|
||||
if key not in excluded_fields:
|
||||
setattr(user, key, value)
|
||||
|
||||
user.updated_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info(f"更新用户成功: {user.alias} (ID: {user.id})")
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def update_user_profile(user_id: int, profile_data: UserUpdateProfile, db: Session) -> User:
|
||||
"""
|
||||
更新用户个人信息(别名、邮箱和密码)
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
profile_data: 个人信息更新数据
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
更新后的用户对象
|
||||
"""
|
||||
from backend.services.auth_service import AuthService
|
||||
|
||||
user = UserService.get_user_by_id(user_id, db)
|
||||
if not user:
|
||||
raise ValueError(f"用户 ID {user_id} 不存在")
|
||||
|
||||
update_data = profile_data.model_dump(exclude_unset=True)
|
||||
|
||||
# 更新别名
|
||||
if "alias" in update_data and update_data["alias"] != user.alias:
|
||||
existing_user = db.query(User).filter(User.alias == update_data["alias"]).first()
|
||||
if existing_user:
|
||||
raise ValueError(f"用户别名 {update_data['alias']} 已存在")
|
||||
user.alias = update_data["alias"]
|
||||
logger.info(f"用户 ID {user_id} 别名更新: {user.alias}")
|
||||
|
||||
# 更新邮箱
|
||||
if "email" in update_data:
|
||||
user.email = update_data["email"]
|
||||
logger.info(f"用户 ID {user_id} 邮箱更新: {user.email}")
|
||||
|
||||
# 更新密码
|
||||
if "new_password" in update_data and update_data["new_password"]:
|
||||
# 如果用户已设置密码,需要验证当前密码
|
||||
if user.password_hash:
|
||||
if "current_password" not in update_data or not update_data["current_password"]:
|
||||
raise ValueError("修改密码时必须提供当前密码")
|
||||
|
||||
# 验证当前密码
|
||||
if not AuthService.verify_password(update_data["current_password"], user.password_hash):
|
||||
raise ValueError("当前密码错误")
|
||||
|
||||
# 设置新密码
|
||||
user.password_hash = AuthService.hash_password(update_data["new_password"])
|
||||
logger.info(f"用户 ID {user_id} 密码已更新")
|
||||
|
||||
user.updated_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info(f"✅ 更新用户个人信息成功: {user.alias} (ID: {user.id})")
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def delete_user(user_id: int, db: Session) -> bool:
|
||||
"""
|
||||
删除用户
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
"""
|
||||
user = UserService.get_user_by_id(user_id, db)
|
||||
if not user:
|
||||
raise ValueError(f"用户 ID {user_id} 不存在")
|
||||
|
||||
alias = user.alias
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"删除用户成功: {alias} (ID: {user_id})")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_users_by_role(role: str, db: Session) -> List[User]:
|
||||
"""
|
||||
获取指定角色的用户
|
||||
|
||||
Args:
|
||||
role: 角色(user/admin)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
用户列表
|
||||
"""
|
||||
return db.query(User).filter(User.role == role).all()
|
||||
|
||||
@staticmethod
|
||||
def count_users(db: Session, role: Optional[str] = None) -> int:
|
||||
"""
|
||||
统计用户数量
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
role: 角色过滤(可选)
|
||||
|
||||
Returns:
|
||||
用户数量
|
||||
"""
|
||||
query = db.query(User)
|
||||
if role:
|
||||
query = query.filter(User.role == role)
|
||||
return query.count()
|
||||
@@ -0,0 +1,275 @@
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import logging
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from typing import Dict, Any
|
||||
|
||||
from backend.config import settings
|
||||
from backend.workers.email_notifier import send_success_notification, send_failure_notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Chrome 配置路径 - 从设置中读取
|
||||
CHROME_BINARY_PATH = settings.CHROME_BINARY_PATH
|
||||
CHROMEDRIVER_PATH = settings.CHROMEDRIVER_PATH
|
||||
|
||||
|
||||
def get_live_x_api_payload(auth_token: str) -> str:
|
||||
"""
|
||||
启动一个临时的无头浏览器会话,获取新鲜的 x-api-request-payload
|
||||
|
||||
Args:
|
||||
auth_token: 用户的 Authorization Token
|
||||
|
||||
Returns:
|
||||
x-api-request-payload 值,失败返回 None
|
||||
"""
|
||||
logger.info("正在启动临时浏览器会话以监听网络日志...")
|
||||
|
||||
# 根据配置创建 Service
|
||||
if CHROMEDRIVER_PATH:
|
||||
service = Service(executable_path=CHROMEDRIVER_PATH)
|
||||
else:
|
||||
service = Service() # 使用 Selenium Manager 自动管理
|
||||
|
||||
chrome_options = Options()
|
||||
|
||||
# 如果配置了 Chrome 路径,则使用配置的路径
|
||||
if CHROME_BINARY_PATH:
|
||||
chrome_options.binary_location = CHROME_BINARY_PATH
|
||||
|
||||
# 开启性能日志记录功能
|
||||
logging_prefs = {'performance': 'ALL'}
|
||||
chrome_options.set_capability('goog:loggingPrefs', logging_prefs)
|
||||
|
||||
# Headless 模式配置
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
|
||||
chrome_options.add_argument(f'user-agent={user_agent}')
|
||||
chrome_options.add_argument("--headless")
|
||||
chrome_options.add_argument("--no-sandbox")
|
||||
chrome_options.add_argument("--disable-dev-shm-usage")
|
||||
chrome_options.add_argument("--window-size=1920,1080")
|
||||
chrome_options.add_argument('--ignore-certificate-errors')
|
||||
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
|
||||
driver = webdriver.Chrome(service=service, options=chrome_options)
|
||||
|
||||
payload_signature = None
|
||||
try:
|
||||
# 导航到同源空白页,用于设置 Cookie
|
||||
driver.get("https://i.jielong.com/my-class")
|
||||
|
||||
# 注入长期 Token
|
||||
driver.add_cookie({
|
||||
'name': 'token',
|
||||
'value': auth_token,
|
||||
'domain': '.jielong.com'
|
||||
})
|
||||
|
||||
# 导航到触发 API 的页面
|
||||
driver.get("https://i.jielong.com/my-form")
|
||||
|
||||
# 等待并捕获 x-api-request-payload
|
||||
max_wait_time = 20 # 最多等待20秒
|
||||
start_time = time.time()
|
||||
found = False
|
||||
|
||||
while time.time() - start_time < max_wait_time:
|
||||
logs = driver.get_log('performance')
|
||||
for entry in logs:
|
||||
log = json.loads(entry['message'])['message']
|
||||
if log['method'] == 'Network.requestWillBeSent':
|
||||
headers = log.get('params', {}).get('request', {}).get('headers', {})
|
||||
headers_lower = {k.lower(): v for k, v in headers.items()}
|
||||
if 'x-api-request-payload' in headers_lower:
|
||||
payload_signature = headers_lower['x-api-request-payload']
|
||||
logger.info("成功通过网络日志捕获到现场的 x-api-request-payload!")
|
||||
found = True
|
||||
break
|
||||
if found:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
if not payload_signature:
|
||||
raise Exception(f"在 {max_wait_time} 秒内未能通过网络日志捕获到 x-api-request-payload。")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取现场 x-api-request-payload 时失败: {e}")
|
||||
debug_screenshot = os.path.join(settings.BASE_DIR, 'payload_debug.png')
|
||||
driver.save_screenshot(debug_screenshot)
|
||||
|
||||
finally:
|
||||
driver.quit()
|
||||
|
||||
return payload_signature
|
||||
|
||||
|
||||
def perform_check_in(task, user_token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
执行打卡任务
|
||||
|
||||
Args:
|
||||
task: CheckInTask 对象,包含打卡任务配置
|
||||
user_token: 用户的 Authorization Token(从 task.user.authorization 获取)
|
||||
|
||||
Returns:
|
||||
打卡结果字典:
|
||||
- success: 是否成功
|
||||
- status: 状态 (success/failure)
|
||||
- response_text: 响应文本
|
||||
- error_message: 错误信息
|
||||
"""
|
||||
# 从 payload_config 中提取 Signature 用于日志
|
||||
try:
|
||||
payload_dict = json.loads(task.payload_config) if task.payload_config else {}
|
||||
signature = payload_dict.get('Signature', 'Unknown')
|
||||
except:
|
||||
signature = 'Unknown'
|
||||
|
||||
logger.info(f"Selenium打卡: 正在为任务 ID: {task.id} (Signature: {signature}) 执行打卡...")
|
||||
|
||||
if not user_token:
|
||||
error_msg = f"任务 ID: {task.id} (Signature: {signature}) 的 Token 为空,跳过。"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": "",
|
||||
"error_message": error_msg
|
||||
}
|
||||
|
||||
# 获取 x-api-request-payload
|
||||
payload_signature = get_live_x_api_payload(user_token)
|
||||
if not payload_signature:
|
||||
error_msg = f"任务 ID: {task.id} (Signature: {signature}) 未能获取到现场签名,打卡中止。"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": "",
|
||||
"error_message": error_msg
|
||||
}
|
||||
|
||||
try:
|
||||
# 使用任务的 payload_config(从模板生成的完整配置,包含 ThreadId)
|
||||
payload = json.loads(task.payload_config) if task.payload_config else {}
|
||||
|
||||
if not payload.get('ThreadId'):
|
||||
error_msg = f"任务 ID: {task.id} 的 payload_config 缺少 ThreadId"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": "",
|
||||
"error_message": error_msg
|
||||
}
|
||||
|
||||
headers = {
|
||||
'User-Agent': "Mozilla%2f5.0+(Linux%3b+Android+16%3b+wv)+AppleWebKit%2f537.36+(KHTML%2c+like+Gecko)+Chrome%2f142.0.0.0+Safari%2f537.36+QQ%2f9.2.30.31620+QQ%2fMiniApp",
|
||||
'Accept-Encoding': "gzip",
|
||||
'Content-Type': "application/json",
|
||||
'authorization': f"Bearer {user_token}",
|
||||
'x-api-request-referer': "https://appservice.qq.com/1110276759",
|
||||
'x-api-request-payload': payload_signature,
|
||||
'referer': "https://appservice.qq.com/1110276759/8.10.1.7/page-frame.html",
|
||||
'platform': "qq",
|
||||
'x-api-request-mode': "cors",
|
||||
}
|
||||
|
||||
url = "https://api.jielong.com/api/CheckIn/EditRecord"
|
||||
|
||||
# 打印请求详情用于调试
|
||||
payload_json = json.dumps(payload, ensure_ascii=False)
|
||||
logger.info(f"📤 打卡请求详情 - 任务 ID: {task.id} (Signature: {signature})")
|
||||
logger.info(f"📍 URL: {url}")
|
||||
logger.info(f"📦 Payload: {payload_json}")
|
||||
logger.info(f"🔑 x-api-request-payload: {payload_signature[:50]}...")
|
||||
|
||||
response = requests.post(url, data=payload_json, headers=headers)
|
||||
response.raise_for_status()
|
||||
response_text = response.text
|
||||
|
||||
logger.info(f"✉️ 任务 ID: {task.id} (Signature: {signature}) 打卡请求完成!响应: {response_text}")
|
||||
|
||||
# 判断响应内容(参考 V1 实现逻辑)
|
||||
# 使用用户账户的邮箱,而不是任务的邮箱
|
||||
email = task.user.email if task.user else None
|
||||
|
||||
# 情况1: 明确包含"打卡成功" → 成功
|
||||
if "打卡成功" in response_text:
|
||||
logger.info(f"✅ 检测到成功关键字 '打卡成功',打卡成功")
|
||||
if email:
|
||||
send_success_notification(email)
|
||||
return {
|
||||
"success": True,
|
||||
"status": "success",
|
||||
"response_text": response_text,
|
||||
"error_message": ""
|
||||
}
|
||||
|
||||
# 情况2: 不在打卡时间范围 → 标记为时间范围外
|
||||
# 支持多种匹配方式:直接文本匹配、JSON Data 字段、Description 字段
|
||||
elif ("不在打卡时间范围" in response_text or
|
||||
"不在打卡时间" in response_text or
|
||||
'"Data":"不在打卡时间范围"' in response_text or
|
||||
'"Description":"不在打卡时间范围"' in response_text):
|
||||
logger.warning(f"⏰ 检测到'不在打卡时间范围',打卡时间不符")
|
||||
return {
|
||||
"success": False,
|
||||
"status": "out_of_time",
|
||||
"response_text": response_text,
|
||||
"error_message": "不在打卡时间范围内"
|
||||
}
|
||||
|
||||
# 情况3: Token 失效的特征标识 → 失败
|
||||
elif ("登录" in response_text):
|
||||
logger.warning(f"⚠️ 检测到登录失败关键字,Token 可能已失效")
|
||||
if email:
|
||||
send_failure_notification(email)
|
||||
return {
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": response_text,
|
||||
"error_message": "Token 已失效,需要重新授权"
|
||||
}
|
||||
|
||||
# 情况4: 其他响应 → 需要人工确认(标记为异常)
|
||||
else:
|
||||
logger.warning(f"⚠️ 未识别的响应内容,请检查: {response_text[:200]}...")
|
||||
# 标记为未知状态,记录完整响应供后续分析
|
||||
return {
|
||||
"success": False,
|
||||
"status": "unknown",
|
||||
"response_text": response_text,
|
||||
"error_message": "未识别的响应,请人工确认"
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = f"为任务 ID: {task.id} (Signature: {signature}) 打卡时请求失败: {e}"
|
||||
logger.error(error_msg)
|
||||
|
||||
response_text = ""
|
||||
if e.response is not None:
|
||||
response_text = e.response.text
|
||||
logger.error(f"响应状态码: {e.response.status_code}, 响应内容: {response_text}")
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": response_text,
|
||||
"error_message": str(e)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"为任务 ID: {task.id} (Signature: {signature}) 打卡时发生未知错误: {e}"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": "",
|
||||
"error_message": str(e)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import time
|
||||
import logging
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- 邮件模板 ---
|
||||
|
||||
EXPIRATION_HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Token 到期通知</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; background-color: #f4f4f4; color: #333; margin: 20px; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); }}
|
||||
h1 {{ color: #d9534f; }}
|
||||
.message {{ background-color: #fff; padding: 15px; border: 1px solid #ddd; border-radius: 5px; margin-bottom: 20px; }}
|
||||
.important {{ font-weight: bold; color: #d9534f; }}
|
||||
.footer {{ font-size: 0.9em; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>注意!</h1>
|
||||
<div class="message">
|
||||
<p>{name},请注意!</p>
|
||||
<p>您的 <span class="important">token</span> 已经到期,请尽快重新刷新您的 token,否则您的自动打卡功能将会失效。</p>
|
||||
<p><strong>到期时间:</strong> {exp_time}</p>
|
||||
</div>
|
||||
<p class="footer">邮件发送时间: {send_time}</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
SUCCESS_HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>打卡成功通知</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; background-color: #f4f4f4; color: #333; margin: 20px; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); }}
|
||||
h1 {{ color: #5cb85c; }}
|
||||
.message {{ background-color: #fff; padding: 15px; border: 1px solid #ddd; border-radius: 5px; margin-bottom: 20px; }}
|
||||
.important {{ font-weight: bold; color: #5cb85c; }}
|
||||
.footer {{ font-size: 0.9em; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>打卡成功!</h1>
|
||||
<div class="message">
|
||||
<p>{name},您好!</p>
|
||||
<p>系统已于 <span class="important">{send_time}</span> 成功为您完成自动打卡。</p>
|
||||
<p>您无需进行任何操作,此邮件仅作通知。</p>
|
||||
</div>
|
||||
<p class="footer">感谢您的使用!</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
FAILURE_HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>打卡失败通知</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; background-color: #f4f4f4; color: #333; margin: 20px; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); }}
|
||||
h1 {{ color: #d9534f; }}
|
||||
.message {{ background-color: #fff; padding: 15px; border: 1px solid #ddd; border-radius: 5px; margin-bottom: 20px; }}
|
||||
.important {{ font-weight: bold; color: #d9534f; }}
|
||||
.footer {{ font-size: 0.9em; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>通知:自动打卡失败!</h1>
|
||||
<div class="message">
|
||||
<p>{name},您好!</p>
|
||||
<p>系统于 <span class="important">{send_time}</span> 尝试为您自动打卡时失败。</p>
|
||||
<p><strong>失败原因:</strong> 服务器返回 "需要登录",这通常意味着您的 <span class="important">Token 已失效</span>。</p>
|
||||
<p><strong>请您立即刷新您的 Token,以确保后续打卡能够成功。</strong></p>
|
||||
</div>
|
||||
<p class="footer">感谢您的使用!</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def get_email_settings():
|
||||
"""
|
||||
从 config.ini 读取邮件配置
|
||||
|
||||
Returns:
|
||||
dict: 邮件配置,如果配置文件不存在则返回 None
|
||||
"""
|
||||
if not settings.EMAIL_CONFIG_FILE.exists():
|
||||
logger.warning("找不到 config.ini,无法发送邮件")
|
||||
return None
|
||||
|
||||
try:
|
||||
config_parser = configparser.ConfigParser()
|
||||
config_parser.read(settings.EMAIL_CONFIG_FILE, encoding='utf-8')
|
||||
|
||||
if 'Email' not in config_parser:
|
||||
logger.warning("config.ini 中缺少 [Email] 配置段")
|
||||
return None
|
||||
|
||||
return config_parser['Email']
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"读取邮件配置失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _send_email(to_email: str, subject: str, html_content: str, email_settings: dict) -> bool:
|
||||
"""
|
||||
发送邮件
|
||||
|
||||
Args:
|
||||
to_email: 收件人邮箱
|
||||
subject: 邮件主题
|
||||
html_content: HTML 邮件内容
|
||||
email_settings: 邮件配置
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = email_settings['senderemail']
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(html_content, 'html', 'utf-8'))
|
||||
|
||||
with smtplib.SMTP_SSL(email_settings['smtpserver'], int(email_settings['smtpport'])) as server:
|
||||
server.login(email_settings['senderemail'], email_settings['senderpassword'])
|
||||
server.sendmail(msg["From"], msg["To"], msg.as_string())
|
||||
|
||||
logger.info(f"已成功向 {to_email} 发送邮件,主题: {subject}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"向 {to_email} 发送邮件时失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_expiration_notification(email: str, jwt_exp: str) -> bool:
|
||||
"""
|
||||
发送 Token 到期提醒邮件
|
||||
|
||||
Args:
|
||||
email: 收件人邮箱
|
||||
jwt_exp: Token 过期时间戳
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
email_settings = get_email_settings()
|
||||
if not email_settings:
|
||||
return False
|
||||
|
||||
try:
|
||||
exp_time = time.strftime("%Y年%m月%d日 %H:%M:%S", time.localtime(float(jwt_exp)))
|
||||
send_time = time.strftime("%Y年%m月%d日 %H:%M:%S", time.localtime())
|
||||
|
||||
html = EXPIRATION_HTML_TEMPLATE.format(
|
||||
name=email,
|
||||
exp_time=exp_time,
|
||||
send_time=send_time
|
||||
)
|
||||
|
||||
return _send_email(email, "接龙管家Token到期通知", html, email_settings)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送过期通知邮件失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_success_notification(email: str) -> bool:
|
||||
"""
|
||||
发送打卡成功通知邮件
|
||||
|
||||
Args:
|
||||
email: 收件人邮箱
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
email_settings = get_email_settings()
|
||||
if not email_settings:
|
||||
return False
|
||||
|
||||
try:
|
||||
send_time = time.strftime("%Y年%m月%d日 %H:%M:%S", time.localtime())
|
||||
|
||||
html = SUCCESS_HTML_TEMPLATE.format(
|
||||
name=email,
|
||||
send_time=send_time
|
||||
)
|
||||
|
||||
return _send_email(email, "自动打卡成功通知", html, email_settings)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送成功通知邮件失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_failure_notification(email: str) -> bool:
|
||||
"""
|
||||
发送打卡失败通知邮件
|
||||
|
||||
Args:
|
||||
email: 收件人邮箱
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
email_settings = get_email_settings()
|
||||
if not email_settings:
|
||||
return False
|
||||
|
||||
try:
|
||||
send_time = time.strftime("%Y年%m月%d日 %H:%M:%S", time.localtime())
|
||||
|
||||
html = FAILURE_HTML_TEMPLATE.format(
|
||||
name=email,
|
||||
send_time=send_time
|
||||
)
|
||||
|
||||
return _send_email(email, "打卡失败 - 需要刷新Token", html, email_settings)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送失败通知邮件失败: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,262 @@
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
from pathlib import Path
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.common.exceptions import TimeoutException
|
||||
from filelock import FileLock
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Chrome 配置路径
|
||||
BASE_DIR = settings.BASE_DIR
|
||||
|
||||
# 调试文件路径
|
||||
DEBUG_SCREENSHOT_PATH = os.path.join(BASE_DIR, "debug_screenshot.png")
|
||||
DEBUG_PAGE_SOURCE_PATH = os.path.join(BASE_DIR, "debug_page_source.html")
|
||||
|
||||
|
||||
def get_chrome_config():
|
||||
"""获取 Chrome 配置(从 settings 读取)"""
|
||||
return {
|
||||
"chrome_binary": settings.CHROME_BINARY_PATH,
|
||||
"chromedriver": settings.CHROMEDRIVER_PATH
|
||||
}
|
||||
|
||||
|
||||
|
||||
def update_session_file(session_id: str, data: dict) -> None:
|
||||
"""线程安全地写入会话文件"""
|
||||
filepath = settings.SESSION_DIR / f"{session_id}.json"
|
||||
lock_path = settings.SESSION_DIR / f"{session_id}.json.lock"
|
||||
|
||||
try:
|
||||
with FileLock(lock_path, timeout=5):
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"写入会话文件 {filepath} 失败: {e}")
|
||||
|
||||
|
||||
def get_session_status(session_id: str) -> str:
|
||||
"""安全地读取会话文件的状态"""
|
||||
filepath = settings.SESSION_DIR / f"{session_id}.json"
|
||||
lock_path = settings.SESSION_DIR / f"{session_id}.json.lock"
|
||||
|
||||
if not filepath.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with FileLock(lock_path, timeout=5):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if not content:
|
||||
return None
|
||||
data = json.loads(content)
|
||||
return data.get('status')
|
||||
except (IOError, json.JSONDecodeError) as e:
|
||||
logger.error(f"读取会话文件 {filepath} 失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_session_data(session_id: str) -> dict:
|
||||
"""读取完整的会话数据"""
|
||||
filepath = settings.SESSION_DIR / f"{session_id}.json"
|
||||
lock_path = settings.SESSION_DIR / f"{session_id}.json.lock"
|
||||
|
||||
if not filepath.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with FileLock(lock_path, timeout=5):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if not content:
|
||||
return None
|
||||
return json.loads(content)
|
||||
except (IOError, json.JSONDecodeError) as e:
|
||||
logger.error(f"读取会话文件 {filepath} 失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_token_headless(session_id: str, jwt_sub: str = None, alias: str = None, client_ip: str = "") -> None:
|
||||
"""
|
||||
使用 Selenium 获取 QQ 扫码登录的 Token
|
||||
|
||||
Args:
|
||||
session_id: 会话 ID
|
||||
jwt_sub: QQ 用户标识(老用户刷新 Token 时提供,新用户为 None)
|
||||
alias: 用户别名(用于新用户注册)
|
||||
client_ip: 客户端 IP 地址
|
||||
"""
|
||||
driver = None
|
||||
current_step = "初始化"
|
||||
|
||||
try:
|
||||
# 获取 Chrome 配置
|
||||
chrome_config = get_chrome_config()
|
||||
chrome_binary_path = chrome_config["chrome_binary"]
|
||||
chromedriver_path = chrome_config["chromedriver"]
|
||||
|
||||
# 配置 Chrome 选项
|
||||
current_step = "配置 ChromeDriver"
|
||||
logger.info(f"Selenium ({session_id}): {current_step}...")
|
||||
|
||||
chrome_options = Options()
|
||||
|
||||
# 如果指定了自定义 Chrome 路径,则使用
|
||||
if chrome_binary_path:
|
||||
chrome_options.binary_location = chrome_binary_path
|
||||
logger.info(f"Selenium ({session_id}): 使用自定义 Chrome 路径: {chrome_binary_path}")
|
||||
else:
|
||||
logger.info(f"Selenium ({session_id}): 使用系统默认 Chrome")
|
||||
|
||||
# Headless 模式配置
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
|
||||
chrome_options.add_argument(f'user-agent={user_agent}')
|
||||
chrome_options.add_argument("--headless")
|
||||
chrome_options.add_argument("--no-sandbox")
|
||||
chrome_options.add_argument("--disable-dev-shm-usage")
|
||||
chrome_options.add_argument("--window-size=1920,1080")
|
||||
chrome_options.add_argument('--ignore-certificate-errors')
|
||||
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
|
||||
# 启动浏览器
|
||||
current_step = "启动 Chrome 浏览器"
|
||||
logger.info(f"Selenium ({session_id}): {current_step}...")
|
||||
|
||||
# 如果指定了 ChromeDriver 路径,则使用 Service;否则让 Selenium 自动管理
|
||||
if chromedriver_path:
|
||||
service = Service(executable_path=chromedriver_path)
|
||||
driver = webdriver.Chrome(service=service, options=chrome_options)
|
||||
logger.info(f"Selenium ({session_id}): 使用自定义 ChromeDriver: {chromedriver_path}")
|
||||
else:
|
||||
driver = webdriver.Chrome(options=chrome_options)
|
||||
logger.info(f"Selenium ({session_id}): 使用 Selenium Manager 自动管理 ChromeDriver")
|
||||
|
||||
logger.info(f"Selenium ({session_id}): Chrome 浏览器启动成功")
|
||||
current_step = "导航到登录页面"
|
||||
logger.info(f"Selenium ({session_id}): {current_step}...")
|
||||
driver.get("https://i.jielong.com/login?redirectTo=https%3A%2F%2Fi.jielong.com%2F")
|
||||
|
||||
wait = WebDriverWait(driver, 60)
|
||||
|
||||
# --- 步骤 1: 点击切换到 QQ 登录 ---
|
||||
current_step = "查找并点击切换按钮"
|
||||
toggle_button_selector = "div.login-wrap .toggle"
|
||||
logger.info(f"Selenium ({session_id}): {current_step} ({toggle_button_selector})...")
|
||||
toggle_button = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, toggle_button_selector)))
|
||||
toggle_button.click()
|
||||
|
||||
# --- 步骤 2: 勾选同意服务协议 ---
|
||||
current_step = "勾选同意服务协议"
|
||||
checkbox_selector = "input.ant-checkbox-input[type='checkbox']"
|
||||
logger.info(f"Selenium ({session_id}): {current_step} ({checkbox_selector})...")
|
||||
checkbox = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, checkbox_selector)))
|
||||
if not checkbox.is_selected():
|
||||
checkbox.click()
|
||||
logger.info(f"Selenium ({session_id}): 已勾选服务协议")
|
||||
|
||||
# --- 步骤 3: 点击"立即登录"按钮 ---
|
||||
current_step = "点击立即登录按钮"
|
||||
login_button_selector = "button.css-1wli0ry.ant-btn.ant-btn-default.login-btn"
|
||||
logger.info(f"Selenium ({session_id}): {current_step} ({login_button_selector})...")
|
||||
login_button = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, login_button_selector)))
|
||||
login_button.click()
|
||||
|
||||
# --- 步骤 4: 等待二维码加载 ---
|
||||
import time
|
||||
time.sleep(3) # 等待几秒让二维码刷新出来
|
||||
|
||||
current_step = "等待QQ二维码图片加载"
|
||||
qq_qr_image_selector = "#login_container img"
|
||||
logger.info(f"Selenium ({session_id}): {current_step} ({qq_qr_image_selector})...")
|
||||
qr_element = wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, qq_qr_image_selector)))
|
||||
|
||||
logger.info(f"Selenium ({session_id}): 成功找到QQ二维码元素,正在截图...")
|
||||
qr_base64 = qr_element.screenshot_as_base64
|
||||
update_session_file(session_id, {
|
||||
'status': 'waiting_scan',
|
||||
'qr_image_data': qr_base64,
|
||||
'jwt_sub': jwt_sub,
|
||||
'alias': alias, # 新增:保存 alias
|
||||
'client_ip': client_ip # 新增:保存 IP
|
||||
})
|
||||
|
||||
current_step = "等待用户扫描登录 (Cookie 'token' 出现)"
|
||||
cookie_name_to_find = "token"
|
||||
logger.info(f"Selenium ({session_id}): {current_step}...")
|
||||
WebDriverWait(driver, 120, 1).until(lambda d: d.get_cookie(cookie_name_to_find) is not None) # 改为 120 秒(2分钟)
|
||||
|
||||
cookie = driver.get_cookie(cookie_name_to_find)
|
||||
if cookie:
|
||||
logger.info(f"Selenium ({session_id}): 成功在Cookie中捕获到Token!")
|
||||
update_session_file(session_id, {
|
||||
'status': 'success',
|
||||
'token': cookie['value'],
|
||||
'alias': alias, # 保存 alias
|
||||
'client_ip': client_ip # 保存 IP
|
||||
})
|
||||
else:
|
||||
raise Exception("等待Cookie成功但获取失败")
|
||||
|
||||
except TimeoutException:
|
||||
if get_session_status(session_id) == 'success':
|
||||
logger.warning(f"Selenium ({session_id}): 一个并发线程超时,但会话已成功,将忽略此超时。")
|
||||
else:
|
||||
# 释放预占的用户名
|
||||
if alias:
|
||||
from backend.services.registration_manager import registration_manager
|
||||
registration_manager.release_alias(alias, session_id)
|
||||
logger.info(f"超时释放用户名预占: {alias}")
|
||||
|
||||
error_message = f"操作超时!卡在了步骤: '{current_step}'。请检查CSS选择器或网络。"
|
||||
logger.error(f"Selenium ({session_id}): {error_message}")
|
||||
|
||||
# 保存调试信息(仅当 driver 已创建时)
|
||||
if driver:
|
||||
try:
|
||||
driver.save_screenshot(DEBUG_SCREENSHOT_PATH)
|
||||
with open(DEBUG_PAGE_SOURCE_PATH, 'w', encoding='utf-8') as f:
|
||||
f.write(driver.page_source)
|
||||
logger.error(f"Selenium ({session_id}): 调试截图和源码已保存。当前URL: {driver.current_url}")
|
||||
except Exception as debug_error:
|
||||
logger.error(f"Selenium ({session_id}): 保存调试信息失败: {debug_error}")
|
||||
|
||||
update_session_file(session_id, {
|
||||
'status': 'error',
|
||||
'message': error_message,
|
||||
'jwt_sub': jwt_sub
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
if get_session_status(session_id) == 'success':
|
||||
logger.warning(f"Selenium ({session_id}): 一个并发线程出错 ({e}),但会话已成功,将忽略此错误。")
|
||||
else:
|
||||
# 释放预占的用户名
|
||||
if alias:
|
||||
from backend.services.registration_manager import registration_manager
|
||||
registration_manager.release_alias(alias, session_id)
|
||||
logger.info(f"异常释放用户名预占: {alias}")
|
||||
|
||||
logger.error(f"Selenium ({session_id}): 发生未知错误: {e}", exc_info=True)
|
||||
update_session_file(session_id, {
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
'jwt_sub': jwt_sub
|
||||
})
|
||||
|
||||
finally:
|
||||
if driver:
|
||||
try:
|
||||
driver.quit()
|
||||
logger.info(f"Selenium ({session_id}): 浏览器已关闭")
|
||||
except Exception as quit_error:
|
||||
logger.error(f"Selenium ({session_id}): 关闭浏览器失败: {quit_error}")
|
||||
Reference in New Issue
Block a user