refactor: v2

backend & frontend
This commit is contained in:
2026-01-01 18:38:21 +08:00
parent 3d201bc497
commit fdc725b893
109 changed files with 22918 additions and 1135 deletions
+259
View File
@@ -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
```
## 📄 许可证
本项目仅供学习和研究使用。
+309
View File
@@ -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)}"
)
+150
View File
@@ -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)
# 设置限流 Cookie10 分钟)
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)}"
)
+221
View File
@@ -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)}"
)
+251
View File
@@ -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)
+212
View File
@@ -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
+294
View File
@@ -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)}"
)
+65
View File
@@ -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()
+97
View File
@@ -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
View File
@@ -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",
)
+7
View File
@@ -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"]
+31
View File
@@ -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})>"
+39
View File
@@ -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)
+52
View File
@@ -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)
+56
View File
@@ -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)
+29
View File
@@ -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})>"
+39
View File
@@ -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"
+16
View File
@@ -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
+71
View File
@@ -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",
]
+49
View File
@@ -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="用户别名")
+48
View File
@@ -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
+153
View File
@@ -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
+147
View File
@@ -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="字段配置(用于前端渲染表单)")
+64
View File
@@ -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分钟内)
+111
View File
@@ -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)
+85
View File
@@ -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
+477
View File
@@ -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:
# 清洗 TokenURL 解码 + 去除 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
+588
View File
@@ -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
+301
View File
@@ -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)
+217
View File
@@ -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()
+382
View File
@@ -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}")
+386
View File
@@ -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)}")
+568
View File
@@ -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)}"
)
+289
View File
@@ -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()
+275
View File
@@ -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)
}
+240
View File
@@ -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
+262
View File
@@ -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}")