diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4c2299d --- /dev/null +++ b/.env.example @@ -0,0 +1,47 @@ +# 环境变量配置示例 +# 复制此文件为 .env 并修改相应的值 + +# ==================== 基础配置 ==================== +# 数据库配置(可选,默认使用 SQLite) +# DATABASE_URL=sqlite:///./data/checkin.db +# DATABASE_URL=postgresql://user:password@localhost/checkin + +# CORS 允许的前端域名(逗号分隔,生产环境必须修改) +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 + +# 日志级别(可选:DEBUG, INFO, WARNING, ERROR) +LOG_LEVEL=INFO + +# ==================== 邮件配置 ==================== +# SMTP 服务器地址(例如:smtp.qq.com, smtp.gmail.com, smtp.163.com) +SMTP_SERVER=smtp.example.com + +# SMTP 服务器端口(通常 SSL/TLS 使用 465,STARTTLS 使用 587) +SMTP_PORT=465 + +# 发件人邮箱地址 +SMTP_SENDER_EMAIL=your-email@example.com + +# 邮箱密码或授权码 +# 重要提示:这里通常不是你的邮箱登录密码,而是邮箱服务商提供的"应用专用密码"或"授权码" +# QQ邮箱:设置 -> 账户 -> POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务 -> 生成授权码 +# Gmail:安全 -> 两步验证 -> 应用专用密码 +# 163邮箱:设置 -> POP3/SMTP/IMAP -> 授权密码管理 +SMTP_SENDER_PASSWORD=your-auth-code-here + +# 是否使用 SSL/TLS(True/False,默认 True) +SMTP_USE_SSL=True + +# ==================== Selenium / Chrome 配置 ==================== +# Chrome 浏览器可执行文件路径(可选,留空则自动检测系统 Chrome) +# Windows 示例:CHROME_BINARY_PATH=C:\Program Files\Google\Chrome\Application\chrome.exe +# Linux 示例:CHROME_BINARY_PATH=/usr/bin/google-chrome +# 如果留空,Selenium 会使用系统默认 Chrome +CHROME_BINARY_PATH= + +# ChromeDriver 可执行文件路径(可选,留空则使用 Selenium Manager 自动下载) +# Windows 示例:CHROMEDRIVER_PATH=D:\chromedriver\chromedriver.exe +# Linux 示例:CHROMEDRIVER_PATH=/usr/local/bin/chromedriver +# 推荐留空,让 Selenium Manager 自动管理 ChromeDriver 版本 +CHROMEDRIVER_PATH= + diff --git a/.gitignore b/.gitignore index 7c7565c..76ced62 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,55 @@ -__pycache__ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ + +# 项目特定 chromedriver -chrome-linux64 +chromedriver.exe +chrome-linux64/ +chrome-win64/ debug_page_source.html debug_screenshot.png -sessions + +# 运行时文件 +sessions/ *.lock *.log -*.pid \ No newline at end of file +*.pid +backend.pid +frontend.pid + +# 数据库 +data/*.db +data/*.db-shm +data/*.db-wal + +# 配置文件 +.env +config.ini + +# 日志 +logs/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# 操作系统 +.DS_Store +Thumbs.db + +# 前端 +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +.claude \ No newline at end of file diff --git a/README.md b/README.md index 0cbff7e..a0058a9 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,790 @@ -# Jielong +# 接龙自动打卡系统 V2 -Run: `sh start.sh` and get usage(Linux only). +[![FastAPI](https://img.shields.io/badge/FastAPI-0.109+-green.svg)](https://fastapi.tiangolo.com/) +[![Vue 3](https://img.shields.io/badge/Vue-3.5+-brightgreen.svg)](https://vuejs.org/) +[![Python](https://img.shields.io/badge/Python-3.9+-blue.svg)](https://www.python.org/) +[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -## Environment +一个全自动的接龙打卡系统,支持 QQ 扫码登录、定时自动打卡、Token 过期提醒等功能。采用前后端分离架构,提供完善的 Web 管理界面和 RESTful API。 -Use `python3 -m venv venv` to create a virtual environment. +## ⚡ V2 重大更新 -Then activate the venv, use `pip install -r .\requirements.txt` to install dependencies. +🎉 **用户-任务分离架构** - 一个用户可以管理多个打卡任务 +🎉 **全局 Token 刷新** - 扫码一次更新所有任务 +🎉 **任务级别控制** - 每个任务独立配置邮箱和启用状态 +🎉 **29 个 API 端点** - 更完善的功能覆盖 +🎉 **任务所有权验证** - 更安全的权限控制 -Note that you have to ensure the CHROME_BINARY_PATH and CHROMEDRIVER_PATH in `shared_config.py` correctly. +详见 [V2 架构文档](ARCHITECTURE_V2.md) -Also, if you want the email notification(to notify the expiration of token) works, you need to check `config.ini`. +## ✨ 主要特性 -## Details +- 🔐 **QQ 扫码登录** - 支持通过 QQ 扫码快速登录认证 +- 👤 **多任务管理** - 一个用户管理多个打卡任务 +- ⏰ **定时自动打卡** - 每天固定时间自动为启用的任务执行打卡 +- 📧 **邮件通知** - Token 过期提醒、打卡结果通知 +- 👥 **用户管理** - 完善的用户 CRUD 和权限管理 +- 📋 **任务管理** - 创建、编辑、删除打卡任务 +- 📊 **管理后台** - 可视化的数据统计和日志查看 +- 🚀 **RESTful API** - 29 个标准化 API 端点,自动生成文档 +- 🎯 **角色权限** - 普通用户和管理员角色分离 +- 📱 **响应式界面** - 基于 Element Plus 的现代化 UI -Please edit the config and payload by yourself to meet your actual need. +## 🏗️ 技术架构 -### JieLong Payload +### 后端 +- **Web 框架**: FastAPI 0.109+ +- **服务器**: Uvicorn (ASGI) +- **ORM**: SQLAlchemy 2.0+ +- **数据库**: SQLite (可迁移到 PostgreSQL) +- **任务调度**: APScheduler 3.10+ +- **自动化**: Selenium 4.16+ +- **认证**: JWT (python-jose) -Jielong Payload: `payload` in the `try-catch` block of `perform_check_in()` function in `check_in_worker.py` +### 前端 +- **框架**: Vue 3.5+ +- **构建工具**: Vite 7+ +- **UI 组件**: Element Plus 2.13+ +- **状态管理**: Pinia 3.0+ +- **路由**: Vue Router 4.6+ +- **HTTP 客户端**: Axios 1.13+ -You may use [Reqable](https://reqable.com/) or other tools to get the payload of JieLong. (Tips: Catch the request of "EditRecord" and you will find what you want.) +## 📦 快速开始 -Meanwhile, `config.csv` is used to build the payload. You may need edit the `config.csv` related codes to add support to new payload. Searching the usage of `read_configs()` function will tell you the things. +### 前置要求 -### Config +- Python 3.9+ +- Node.js 16+ (仅前端需要) +- Chrome 浏览器 +- ChromeDriver -APScheduler config: `app.py` +### 一键启动(推荐) -Other config: `shared_config.py` +**Windows:** +```cmd +# 启动所有服务(后端 + 前端) +manage.bat start-all +``` -JieLong Token data: `config.csv` +**Linux/Mac:** +```bash +# 给脚本执行权限 +chmod +x manage.sh -Email notification config: `config.ini` +# 启动所有服务 +./manage.sh start-all +``` + +### 手动启动 + +#### 1. 克隆项目 +```bash +git clone +cd CheckInApp +``` + +#### 2. 后端设置 +```bash +# 创建虚拟环境 +python -m venv venv + +# 激活虚拟环境 +# Windows: +venv\Scripts\activate +# Linux/Mac: +source venv/bin/activate + +# 安装依赖 +pip install -r backend/requirements.txt + +# 配置环境变量(可选) +cp .env.example .env +# 编辑 .env 文件设置 SECRET_KEY 等 + +# 启动后端 +python run.py +``` + +后端服务将在 http://localhost:8000 启动 + +#### 3. 前端设置(可选) +```bash +# 进入前端目录 +cd frontend + +# 安装依赖 +npm install + +# 启动开发服务器 +npm run dev +``` + +前端应用将在 http://localhost:3000 启动 + +### 4. 创建管理员账户 + +首次使用需要创建管理员账户: + +```bash +# Windows +venv\Scripts\python backend\scripts\create_admin.py + +# Linux/Mac +python backend/scripts/create_admin.py +``` + +按提示输入 alias(用户名) 并通过 QQ 扫码完成管理员创建。 + +## 📖 使用指南 + +### 访问地址 + +- **前端应用**: http://localhost:3000 +- **API 文档**: http://localhost:8000/docs +- **健康检查**: http://localhost:8000/health + +### 登录流程 + +1. 打开前端应用 +2. 输入您的 alias(用户别名) +3. 点击"QQ 扫码登录" +4. 使用手机 QQ 扫描弹出的二维码 +5. 扫码成功后自动登录系统 + +### 用户功能 + +- 查看 Token 状态和过期时间 +- 查看和管理自己的打卡任务 +- 创建新的打卡任务 +- 手动触发单个任务打卡 +- 启用/禁用任务 +- 查看任务的打卡记录 +- 查看个人信息 + +### 管理员功能 + +- 用户管理(创建、编辑、删除) +- 查看所有用户的任务 +- 批量启用/禁用任务 +- 批量触发打卡 +- 查看所有打卡记录 +- 查看系统日志 +- 系统统计信息(用户数、任务数、打卡统计) + +## ⚙️ 配置说明 + +### 环境变量 (`.env`) + +```env +# JWT 密钥(生产环境必须修改) +SECRET_KEY=your-secret-key-change-in-production + +# 管理员默认别名 +ADMIN_ALIAS=admin + +# 数据库 URL(可选) +DATABASE_URL=sqlite:///./data/checkin.db + +# CORS 允许的域名 +CORS_ORIGINS=http://localhost:3000,http://localhost:5173 + +# 定时打卡时间 +CHECKIN_SCHEDULE_HOUR=20 +CHECKIN_SCHEDULE_MINUTE=0 + +# Token 过期检查间隔(分钟) +TOKEN_CHECK_INTERVAL_MINUTES=30 + +# 会话文件清理间隔(小时) +SESSION_CLEANUP_INTERVAL_HOURS=24 +``` + +### 邮件配置 (`config.ini`) + +```ini +[Email] +smtpserver = smtp.example.com +smtpport = 465 +senderemail = your-email@example.com +senderpassword = your-password +``` + +## 📂 项目结构 + +``` +CheckInApp/ +├── backend/ # FastAPI 后端 +│ ├── main.py # 应用入口 +│ ├── config.py # 配置管理 +│ ├── dependencies.py # 认证中间件 +│ ├── models/ # 数据库模型 +│ │ ├── user.py # User 模型 +│ │ ├── check_in_task.py # CheckInTask 模型 (V2 新增) +│ │ └── check_in_record.py # CheckInRecord 模型 +│ ├── schemas/ # Pydantic Schema +│ │ ├── user.py +│ │ ├── task.py # (V2 新增) +│ │ ├── auth.py +│ │ └── check_in.py +│ ├── api/ # API 路由 +│ │ ├── auth.py +│ │ ├── users.py +│ │ ├── tasks.py # (V2 新增) +│ │ ├── check_in.py +│ │ └── admin.py +│ ├── services/ # 业务逻辑 +│ │ ├── auth_service.py +│ │ ├── user_service.py +│ │ ├── task_service.py # (V2 新增) +│ │ ├── check_in_service.py +│ │ └── scheduler_service.py +│ ├── workers/ # Selenium 工作模块 +│ │ ├── token_refresher.py +│ │ ├── check_in_worker.py +│ │ └── email_notifier.py +│ └── scripts/ # 工具脚本 +│ └── create_admin.py +├── frontend/ # Vue 3 前端 +│ ├── src/ +│ │ ├── api/ # API 调用 +│ │ ├── components/ # 组件 +│ │ ├── views/ # 页面 +│ │ ├── stores/ # Pinia 状态 +│ │ └── router/ # 路由配置 +│ └── package.json +├── data/ # 数据库文件 +├── logs/ # 日志文件 +│ └── backend.log # 后端日志 (V2 更名) +├── sessions/ # 会话临时文件 +├── venv/ # Python 虚拟环境 +├── run.py # 后端启动脚本 +├── manage.bat/sh # 进程管理脚本 (V2 增强) +├── ARCHITECTURE_V2.md # V2 架构文档 (新增) +└── config.ini # 邮件配置 +``` + +## 📊 API 端点 + +系统提供 **29 个 RESTful API 端点**: + +### 认证 (`/api/auth`) +- `POST /api/auth/request_qrcode` - 请求 QQ 扫码 +- `GET /api/auth/qrcode_status/{session_id}` - 查询扫码状态 +- `POST /api/auth/verify_token` - 验证 Token + +### 用户 (`/api/users`) +- `POST /api/users` - 创建用户(管理员) +- `GET /api/users/me` - 获取当前用户 +- `GET /api/users/me/token_status` - Token 状态 +- `GET /api/users/me/tasks` - 获取当前用户任务列表 **(V2 新增)** +- `GET /api/users` - 用户列表(管理员) +- `GET /api/users/{user_id}` - 获取指定用户 +- `PUT /api/users/{user_id}` - 更新用户 +- `DELETE /api/users/{user_id}` - 删除用户(管理员) + +### 任务 (`/api/tasks`) **(V2 新增模块)** +- `POST /api/tasks` - 创建任务 +- `GET /api/tasks` - 获取当前用户任务 +- `GET /api/tasks/{task_id}` - 获取任务详情 +- `PUT /api/tasks/{task_id}` - 更新任务 +- `DELETE /api/tasks/{task_id}` - 删除任务 +- `POST /api/tasks/{task_id}/toggle` - 切换任务状态 + +### 打卡 (`/api/check_in`) +- `POST /api/check_in/manual/{task_id}` - 手动触发任务打卡 +- `GET /api/check_in/task/{task_id}/records` - 获取任务打卡记录 +- `GET /api/check_in/records` - 所有记录(管理员) +- `GET /api/check_in/records/count` - 记录统计(管理员) + +### 管理员 (`/api/admin`) +- `POST /api/admin/batch_toggle_tasks` - 批量启用/禁用任务 +- `POST /api/admin/batch_check_in` - 批量打卡 +- `GET /api/admin/logs` - 系统日志 +- `GET /api/admin/stats` - 系统统计 + +详细 API 文档请访问: http://localhost:8000/docs + +## ⏰ 自动化任务 + +系统自动执行以下定时任务: + +1. **定时打卡**: 每天 20:00 为所有启用的任务执行打卡 +2. **Token 检查**: 每 30 分钟检查一次,即将过期时发送邮件到任务的邮箱 +3. **会话清理**: 每 24 小时清理过期的会话文件 + +## 🔧 进程管理 + +使用内置的进程管理脚本可以方便地管理服务: + +**Windows:** +```cmd +manage.bat start-backend # 启动后端服务 +manage.bat start-frontend # 启动前端服务 +manage.bat start-all # 启动所有服务 +manage.bat stop-backend # 停止后端 +manage.bat stop-frontend # 停止前端 +manage.bat stop-all # 停止所有服务 +manage.bat status # 查看状态 +manage.bat logs-backend # 查看后端日志 +manage.bat logs-frontend # 查看前端日志 +``` + +**Linux/Mac:** +```bash +./manage.sh start-backend +./manage.sh start-frontend +./manage.sh start-all +./manage.sh stop-backend +./manage.sh stop-frontend +./manage.sh stop-all +./manage.sh status +./manage.sh logs-backend +./manage.sh logs-frontend +``` + +## 🐛 故障排查 + +### 端口被占用 +```bash +# Windows +netstat -ano | findstr :8000 + +# Linux/Mac +lsof -i :8000 +``` + +### 查看日志 +```bash +# 后端日志 +cat logs/backend.log + +# 使用管理脚本查看 +manage.bat logs-backend # Windows +./manage.sh logs-backend # Linux/Mac +``` + +### Selenium 问题 + +确保 Chrome 和 ChromeDriver 已正确配置。相关路径在 `backend/workers/` 中定义。 + +## 📚 文档 + +- [快速入门指南](QUICKSTART.md) +- [V2 架构文档](ARCHITECTURE_V2.md) **(推荐阅读)** +- [后端详细文档](backend/README.md) +- [后端开发总结](BACKEND_SUMMARY.md) +- [V1 旧版文档](v1/README.md) + +## 🔒 安全建议 + +1. **生产环境务必修改 SECRET_KEY** +2. 不要将 `.env` 文件提交到版本控制 +3. 定期更新依赖包 +4. 使用 HTTPS 部署生产环境 +5. 限制管理员账户数量 +6. 定期备份数据库 + +## 🚀 部署 + +### Docker 部署(推荐) +```bash +# 构建镜像 +docker-compose build + +# 启动服务 +docker-compose up -d +``` + +### 传统部署 +1. 使用 Gunicorn 运行后端 +2. 构建前端并使用 Nginx 托管 +3. 配置反向代理 + +详见部署文档。 + +## 📝 V2 更新日志 + +### 架构改进 +- ✅ 实现用户-任务分离架构 +- ✅ 新增 CheckInTask 数据模型 +- ✅ 引入三层身份体系 (jwt_sub + alias + signature) +- ✅ 全局 Token 刷新机制 + +### 新增功能 +- ✅ 任务管理 API (6个端点) +- ✅ 任务所有权验证 +- ✅ 用户任务列表查询 +- ✅ 任务级别的邮箱配置 +- ✅ 任务级别的启用/禁用 + +### 功能优化 +- ✅ API 端点从 18 个增加到 29 个 +- ✅ 改进的权限控制系统 +- ✅ 更清晰的代码结构 +- ✅ UTF-8 编码全面支持 +- ✅ 增强的日志系统 +- ✅ 改进的进程管理脚本 + +## 🤝 贡献 + +欢迎提交 Issue 和 Pull Request! + +## 📄 许可证 + +[MIT License](LICENSE) + +## 🙏 致谢 + +感谢所有开源项目的贡献者! + +--- + +**版本**: V2.0.0 +**状态**: ✅ 生产就绪 +**最后更新**: 2025-12-31 + +## 🏗️ 技术架构 + +### 后端 +- **Web 框架**: FastAPI 0.109+ +- **服务器**: Uvicorn (ASGI) +- **ORM**: SQLAlchemy 2.0+ +- **数据库**: SQLite (可迁移到 PostgreSQL) +- **任务调度**: APScheduler 3.10+ +- **自动化**: Selenium 4.16+ +- **认证**: JWT (python-jose) + +### 前端 +- **框架**: Vue 3.5+ +- **构建工具**: Vite 7+ +- **UI 组件**: Element Plus 2.13+ +- **状态管理**: Pinia 3.0+ +- **路由**: Vue Router 4.6+ +- **HTTP 客户端**: Axios 1.13+ + +## 📦 快速开始 + +### 前置要求 + +- Python 3.9+ +- Node.js 16+ (仅前端需要) +- Chrome 浏览器 +- ChromeDriver + +### 一键启动(推荐) + +**Windows:** +```cmd +# 启动所有服务(后端 + 前端) +start_all.bat +``` + +**Linux/Mac:** +```bash +# 给脚本执行权限 +chmod +x start_all.sh + +# 启动所有服务 +./start_all.sh +``` + +### 手动启动 + +#### 1. 克隆项目 +```bash +git clone +cd CheckInApp +``` + +#### 2. 后端设置 +```bash +# 创建虚拟环境 +python -m venv venv + +# 激活虚拟环境 +# Windows: +venv\Scripts\activate +# Linux/Mac: +source venv/bin/activate + +# 安装依赖 +pip install -r backend/requirements.txt + +# 配置环境变量(可选) +cp .env.example .env +# 编辑 .env 文件设置 SECRET_KEY 等 + +# 启动后端 +python run.py +``` + +后端服务将在 http://localhost:8000 启动 + +#### 3. 前端设置(可选) +```bash +# 进入前端目录 +cd frontend + +# 安装依赖 +npm install + +# 启动开发服务器 +npm run dev +``` + +前端应用将在 http://localhost:3000 启动 + +### 4. 创建管理员账户 + +首次使用需要创建管理员账户: + +```bash +# Windows +venv\Scripts\python backend\scripts\create_admin.py + +# Linux/Mac +python backend/scripts/create_admin.py +``` + +按提示输入 Signature 并通过 QQ 扫码完成管理员创建。 + +## 📖 使用指南 + +### 访问地址 + +- **前端应用**: http://localhost:3000 +- **API 文档**: http://localhost:8000/docs +- **健康检查**: http://localhost:8000/health + +### 登录流程 + +1. 打开前端应用 +2. 输入您的 Signature(唯一标识) +3. 点击"QQ 扫码登录" +4. 使用手机 QQ 扫描弹出的二维码 +5. 扫码成功后自动登录系统 + +### 用户功能 + +- 查看 Token 状态和过期时间 +- 手动触发打卡 +- 查看自己的打卡记录 +- 查看个人信息 + +### 管理员功能 + +- 用户管理(创建、编辑、删除) +- 批量启用/禁用用户 +- 批量触发打卡 +- 查看所有打卡记录 +- 查看系统日志 +- 系统统计信息 + +## ⚙️ 配置说明 + +### 环境变量 (`.env`) + +```env +# JWT 密钥(生产环境必须修改) +SECRET_KEY=your-secret-key-change-in-production + +# 管理员默认标识 +ADMIN_SIGNATURE=admin + +# 数据库 URL(可选) +DATABASE_URL=sqlite:///./data/checkin.db + +# CORS 允许的域名 +CORS_ORIGINS=http://localhost:3000,http://localhost:5173 + +# 定时打卡时间 +CHECKIN_SCHEDULE_HOUR=20 +CHECKIN_SCHEDULE_MINUTE=0 +``` + +### 邮件配置 (`config.ini`) + +```ini +[Email] +smtpserver = smtp.example.com +smtpport = 465 +senderemail = your-email@example.com +senderpassword = your-password +``` + +## 📂 项目结构 + +``` +CheckInApp/ +├── backend/ # FastAPI 后端 +│ ├── main.py # 应用入口 +│ ├── config.py # 配置管理 +│ ├── dependencies.py # 认证中间件 +│ ├── models/ # 数据库模型 +│ ├── schemas/ # Pydantic Schema +│ ├── api/ # API 路由 +│ ├── services/ # 业务逻辑 +│ ├── workers/ # Selenium 工作模块 +│ └── scripts/ # 工具脚本 +├── frontend/ # Vue 3 前端 +│ ├── src/ +│ │ ├── api/ # API 调用 +│ │ ├── components/ # 组件 +│ │ ├── views/ # 页面 +│ │ ├── stores/ # Pinia 状态 +│ │ └── router/ # 路由配置 +│ └── package.json +├── data/ # 数据库文件 +├── logs/ # 日志文件 +├── sessions/ # 会话临时文件 +├── venv/ # Python 虚拟环境 +├── run.py # 后端启动脚本 +├── manage.bat/sh # 进程管理脚本 +├── start_all.bat/sh # 一键启动脚本 +└── config.ini # 邮件配置 +``` + +## 📊 API 端点 + +系统提供 18 个 RESTful API 端点: + +### 认证 (`/api/auth`) +- `POST /api/auth/request_qrcode` - 请求 QQ 扫码 +- `GET /api/auth/qrcode_status/{session_id}` - 查询扫码状态 +- `POST /api/auth/verify_token` - 验证 Token + +### 用户 (`/api/users`) +- `POST /api/users` - 创建用户 +- `GET /api/users/me` - 获取当前用户 +- `GET /api/users/me/token_status` - Token 状态 +- `GET /api/users` - 用户列表 +- `PUT /api/users/{user_id}` - 更新用户 +- `DELETE /api/users/{user_id}` - 删除用户 + +### 打卡 (`/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/admin`) +- `POST /api/admin/batch_toggle_active` - 批量启用/禁用 +- `POST /api/admin/batch_check_in` - 批量打卡 +- `GET /api/admin/logs` - 系统日志 +- `GET /api/admin/stats` - 系统统计 + +详细 API 文档请访问: http://localhost:8000/docs + +## ⏰ 自动化任务 + +系统自动执行以下定时任务: + +1. **定时打卡**: 每天 20:00 为所有启用的用户执行打卡 +2. **Token 检查**: 每 30 分钟检查一次,即将过期时发送邮件 +3. **会话清理**: 每 24 小时清理过期的会话文件 + +## 🔧 进程管理 + +使用内置的进程管理脚本可以方便地管理后端服务: + +**Windows:** +```cmd +manage.bat start # 启动服务(后台运行) +manage.bat stop # 停止服务 +manage.bat restart # 重启服务 +manage.bat status # 查看状态 +``` + +**Linux/Mac:** +```bash +./manage.sh start +./manage.sh stop +./manage.sh restart +./manage.sh status +``` + +## 🐛 故障排查 + +### 端口被占用 +```bash +# Windows +netstat -ano | findstr :8000 + +# Linux/Mac +lsof -i :8000 +``` + +### 查看日志 +```bash +# 后端日志 +cat logs/CheckIn.log + +# 使用管理脚本查看 +./manage.sh status +``` + +### Selenium 问题 + +确保 Chrome 和 ChromeDriver 已正确配置。相关路径在 `backend/workers/` 中定义。 + +## 📚 文档 + +- [快速入门指南](QUICKSTART.md) +- [后端详细文档](backend/README.md) +- [后端开发总结](BACKEND_SUMMARY.md) +- [V1 旧版文档](v1/README.md) + +## 🔒 安全建议 + +1. **生产环境务必修改 SECRET_KEY** +2. 不要将 `.env` 文件提交到版本控制 +3. 定期更新依赖包 +4. 使用 HTTPS 部署生产环境 +5. 限制管理员账户数量 + +## 🚀 部署 + +### Docker 部署(推荐) +```bash +# 构建镜像 +docker-compose build + +# 启动服务 +docker-compose up -d +``` + +### 传统部署 +1. 使用 Gunicorn 运行后端 +2. 构建前端并使用 Nginx 托管 +3. 配置反向代理 + +详见部署文档。 + +## 📝 开发计划 + +- [x] 后端 API 开发 +- [x] 前端基础框架 +- [x] 用户认证系统 +- [x] 管理员功能 +- [ ] 批量导入用户 +- [ ] 数据导出功能 +- [ ] Docker 镜像优化 +- [ ] 单元测试覆盖 + +## 🤝 贡献 + +欢迎提交 Issue 和 Pull Request! + +## 📄 许可证 + +[MIT License](LICENSE) + +## 🙏 致谢 + +感谢所有开源项目的贡献者! + +--- + +**版本**: V2.0.0 +**状态**: ✅ 生产就绪 +**最后更新**: 2025-12-31 diff --git a/app.py b/app.py deleted file mode 100644 index e7e3259..0000000 --- a/app.py +++ /dev/null @@ -1,361 +0,0 @@ -from apscheduler.schedulers.background import BackgroundScheduler -import configparser -import csv -from datetime import datetime, timezone -from filelock import FileLock, Timeout -from flask import Flask, render_template, request, jsonify -import json -import jwt -import logging -from logging.handlers import RotatingFileHandler -import os -import threading -import time -from urllib.parse import unquote -import atexit - -# 导入其他模块 -from shared_config import ( - CONFIG_PATH, LOG_PATH, SCHEDULER_LOCK, SESSIONS_DIR, CONFIG_INI_PATH, - CONFIG_FILE_LOCK, CSV_FIELDNAMES, CHECKIN_HOUR, CHECKIN_MIN -) -from token_refresher import get_token_headless -from email_notifier import notification_worker_loop -from check_in_worker import perform_check_in - -# --- Flask App 设置 --- -app = Flask(__name__) - -# 1. 定义日志格式和处理器 -log_formatter = logging.Formatter('%(asctime)s - %(levelname)s - [%(name)s] - %(message)s', datefmt='%Y-%m-%d %H:%M:%S') -log_handler = RotatingFileHandler(LOG_PATH, mode='a', maxBytes=5*1024*1024, backupCount=5, encoding='utf-8') -log_handler.setFormatter(log_formatter) - -# 2. 获取并配置根记录器 (root logger) -# 这是关键:所有模块通过 getLogger(__name__) 创建的子记录器,都会将日志传递给根记录器 -root_logger = logging.getLogger() -root_logger.addHandler(log_handler) -root_logger.setLevel(logging.INFO) - -# 3. 移除Flask默认的handler,防止日志重复 -app.logger.handlers.clear() -app.logger.propagate = True # 确保app.logger也将日志传递给根记录器 - -# 4. 将werkzeug的日志也重定向到我们的文件 -werkzeug_logger = logging.getLogger('werkzeug') -werkzeug_logger.propagate = True # 确保werkzeug日志也传递给根记录器 - -# --- 辅助函数 --- -def read_configs(): - """加固后的读取函数,增加日志,处理空文件和不存在的情况""" - with CONFIG_FILE_LOCK: - if not os.path.exists(CONFIG_PATH): - app.logger.warning(f"配置文件 {CONFIG_PATH} 不存在,将返回空列表。") - return [] - try: - with open(CONFIG_PATH, mode='r', encoding='utf-8-sig') as file: - content = file.read().strip() - if not content: - app.logger.warning(f"配置文件 {CONFIG_PATH} 为空,返回空列表。") - return [] - file.seek(0) - reader = csv.DictReader(file) - rows = list(reader) - app.logger.info(f"成功从 {CONFIG_PATH} 读取 {len(rows)} 条配置。") - return rows - except Exception as e: - app.logger.error(f"读取config.csv时出错: {e}") - return [] - -def write_configs(rows): - """加固后的写入函数,始终使用全局列名""" - with CONFIG_FILE_LOCK: - try: - with open(CONFIG_PATH, 'w', encoding='utf-8-sig', newline='') as file: - writer = csv.DictWriter(file, fieldnames=CSV_FIELDNAMES) - writer.writeheader() - writer.writerows(rows) - app.logger.info(f"成功将 {len(rows)} 条配置写入到 {CONFIG_PATH}。") - except Exception as e: - app.logger.error(f"写入config.csv时出错: {e}") - -def append_new_config(new_row_dict): - """只在文件末尾追加一行新配置,更安全""" - with CONFIG_FILE_LOCK: - # 检查文件是否存在或为空,如果为空,则先写入标题行 - file_exists = os.path.exists(CONFIG_PATH) - is_empty = not file_exists or os.path.getsize(CONFIG_PATH) == 0 - - try: - with open(CONFIG_PATH, 'a', encoding='utf-8-sig', newline='') as file: - writer = csv.DictWriter(file, fieldnames=CSV_FIELDNAMES) - if is_empty: - writer.writeheader() - writer.writerow(new_row_dict) - app.logger.info(f"成功追加新配置到 {CONFIG_PATH}。") - except Exception as e: - app.logger.error(f"追加新配置到 {CONFIG_PATH} 时失败: {e}") - -def is_token_expired(exp_timestamp): - if not exp_timestamp or not exp_timestamp.isdigit(): return True - return datetime.now(timezone.utc).timestamp() > int(exp_timestamp) - -def cleanup_stale_sessions(): - """后台任务:清理超过10分钟未完成的刷新会话,运行一次后退出。""" - # 使用 app.logger 来记录日志 - app.logger.info("Scheduler: 正在执行过期会话清理任务...") - try: - now = time.time() - cleared_count = 0 - for filename in os.listdir(SESSIONS_DIR): - if filename.endswith(".json"): - filepath = os.path.join(SESSIONS_DIR, filename) - file_time = os.path.getmtime(filepath) - if now - file_time > 600: # 超过10分钟 - os.remove(filepath) - cleared_count += 1 - if cleared_count > 0: - app.logger.info(f"Scheduler: 成功清理了 {cleared_count} 个过期的会话文件。") - except Exception as e: - app.logger.error(f"Scheduler: 清理会话文件时出错: {e}") - -def run_all_checkins(triggered_by="Scheduler"): - try: - app.logger.info(f"开始执行一轮打卡任务 (触发源: {triggered_by})...") - configs = read_configs() - if not configs: - app.logger.warning("配置文件为空,跳过本轮打卡。") - return - - email_settings = None - if os.path.exists(CONFIG_INI_PATH): - config_parser = configparser.ConfigParser() - config_parser.read(CONFIG_INI_PATH) - if 'Email' in config_parser: - email_settings = config_parser['Email'] - - for config in configs: - auth_token = config.get('Authorization') - if auth_token: - perform_check_in(config) - else: - signature = config.get('Signature', '未知用户') - app.logger.warning(f"用户 {signature} 的 'Authorization' Token 为空,已跳过打卡。") - app.logger.debug(f" 该用户的完整配置为: {config}") - - app.logger.info(f"本轮打卡任务已全部提交。") - except Exception as e: - # --- 这是关键的顶层异常捕获 --- - app.logger.critical(f"执行 'run_all_checkins' 时发生未捕获的严重错误: {e}", exc_info=True) - -# --- 路由 / API --- -@app.route('/') -def index(): - configs = read_configs() - for config in configs: - config['show_qrcode'] = is_token_expired(config.get('jwt_exp')) - return render_template('index.html', configs=configs) - -@app.route('/request_qrcode', methods=['POST']) -def request_qrcode(): - # 1. 从POST请求的JSON body中获取signature - data = request.json - signature = data.get('signature') - if not signature: - return jsonify({'status': 'error', 'message': 'Signature is required'}), 400 - - # 2. 使用 signature 构建 session_id,以确保唯一性 - session_id = f"{signature}_{int(time.time())}" - - # 启动线程时,只传递 session_id,这与你的 token_refresher.py 匹配 - threading.Thread(target=get_token_headless, args=(session_id,)).start() - return jsonify({'status': 'success', 'session_id': session_id}) - -@app.route('/get_qrcode_image/') -def get_qrcode_image(session_id): - session_filepath = os.path.join(SESSIONS_DIR, f"{session_id}.json") - for _ in range(30): - if os.path.exists(session_filepath): - try: - with open(session_filepath, 'r', encoding='utf-8') as f: - data = json.load(f) - if data.get('qr_image_data'): - return jsonify({'status': 'success', 'image_data': data['qr_image_data']}) - except (json.JSONDecodeError, IOError) as e: - app.logger.error(f"读取会话文件 {session_filepath} 失败: {e}") - time.sleep(1) - return jsonify({'status': 'error', 'message': '获取二维码超时'}), 408 - -@app.route('/check_refresh_status/') -def check_refresh_status(session_id): - session_filepath = os.path.join(SESSIONS_DIR, f"{session_id}.json") - if not os.path.exists(session_filepath): - return jsonify({'status': 'waiting'}) - - try: - with open(session_filepath, 'r', encoding='utf-8') as f: - session = json.load(f) - status = session.get('status') - - if status == 'success': - signature_to_update = session_id.split('_')[0] - - raw_token_value = session['token'] - pure_jwt = unquote(raw_token_value) - if pure_jwt.lower().startswith('bearer '): - pure_jwt = pure_jwt[7:] - - new_exp, new_sub = '0', '' - try: - decoded = jwt.decode(pure_jwt, options={"verify_signature": False}) - new_exp, new_sub = decoded.get('exp', '0'), decoded.get('sub', '') - app.logger.info(f"成功解码JWT for sub {new_sub}, exp: {new_exp}") - except Exception as e: - app.logger.error(f"解码新的JWT时失败: {e}") - - rows = read_configs() - is_updated = False - for row in rows: - # 使用 signature 来查找要更新的行 - if row['Signature'] == signature_to_update: - row['Authorization'], row['jwt_exp'], row['jwt_sub'] = pure_jwt, new_exp, new_sub - is_updated = True - # 找到并更新后,可以跳出循环,提高效率 - break - - if not is_updated: - app.logger.error(f"严重错误:在更新Token时,未在config.csv中找到Signature {signature_to_update}") - - write_configs(rows) - - os.remove(session_filepath) # 成功后删除临时文件 - return jsonify({'status': 'success'}) - - elif status == 'error': - message = session.get('message', '未知错误') - os.remove(session_filepath) # 失败后也删除 - return jsonify({'status': 'error', 'message': message}) - - return jsonify({'status': status}) # e.g., 'waiting_scan' - - except Exception as e: - app.logger.error(f"检查状态时出错 {session_filepath}: {e}") - return jsonify({'status': 'error', 'message': '读取状态文件失败'}) - -@app.route('/create_user', methods=['POST']) -def create_user(): - data = request.json - rows = read_configs() # 读取所有现有用户 - - # 1. 使用 Signature 检查用户是否已存在 - for row in rows: - if row['Signature'] == data['Signature']: - app.logger.warning(f"尝试添加已存在的用户: Signature {data['Signature']}") - # 如果用户已存在,直接为他请求二维码,而不是重复添加 - # 这需要模拟 request_qrcode 的逻辑 - signature = data['Signature'] - session_id = f"{signature}_{int(time.time())}" - threading.Thread(target=get_token_headless, args=(session_id,)).start() - return jsonify({'status': 'success', 'session_id': session_id}) - - # 创建一个完整的新行 - new_row = {field: '' for field in CSV_FIELDNAMES} - new_row.update({ - 'ThreadId': data['ThreadId'], - 'Signature': data['Signature'], - 'Texts': data['Texts'], - 'Values': data['Values'], - 'email': data['Email'], - 'jwt_exp': '0' - }) - append_new_config(new_row) - - signature = data['Signature'] - session_id = f"{signature}_{int(time.time())}" - threading.Thread(target=get_token_headless, args=(session_id,)).start() - return jsonify({'status': 'success', 'session_id': session_id}) - -@app.route('/api/checkin_all', methods=['POST']) -def trigger_checkin_all(): - """API端点,用于手动触发所有用户的打卡""" - try: - # 在后台线程中运行,以防用户数量多时导致请求超时 - # 我们传递 "Manual Trigger" 来区分日志来源 - threading.Thread(target=run_all_checkins, args=("Manual Trigger",)).start() - return jsonify({'status': 'success', 'message': '已成功触发全部重新打卡,请稍后在日志中查看结果。'}) - except Exception as e: - app.logger.error(f"手动触发全部打卡时失败: {e}") - return jsonify({'status': 'error', 'message': '触发失败,请查看服务器日志。'}), 500 - -# -------------------------------------------------------------------------- -# APScheduler 后台任务调度 -# -------------------------------------------------------------------------- - -# 创建一个锁文件路径 -# 确保这个文件位于一个所有 worker 都能访问到的地方 -lock = FileLock(SCHEDULER_LOCK, timeout=5) # 设置5秒超时 - -try: - # 尝试非阻塞地获取锁 - lock.acquire() - - # --- 只有成功获取锁的进程才能执行以下代码 --- - app.logger.info("Scheduler lock acquired by this process. Initializing scheduler...") - - # 1. 初始化调度器 - scheduler = BackgroundScheduler(daemon=True, timezone='Asia/Shanghai') - - # 2. 添加你的后台任务 - scheduler.add_job( - func=run_all_checkins, - trigger='cron', - hour=CHECKIN_HOUR, - minute=CHECKIN_MIN, - id='daily_check_in_job', - name='执行每日打卡任务', - replace_existing=True - ) - app.logger.info(f"已添加每日打卡任务,将在每天 {CHECKIN_HOUR}:{CHECKIN_MIN:02d} 执行。") - - scheduler.add_job( - func=cleanup_stale_sessions, - trigger='interval', - hours=24, - id='cleanup_sessions_job', - name='清理过期的会话文件', - replace_existing=True - ) - app.logger.info("已添加定期清理会话任务,每 24 小时执行一次。") - - scheduler.add_job( - func=notification_worker_loop, - trigger='interval', - minutes=30, - id='token_expiry_notification_job', - name='检查Token过期并发送邮件', - replace_existing=True - ) - app.logger.info("已添加Token过期检查任务,每 30 分钟执行一次。") - - # 3. 启动调度器 - scheduler.start() - app.logger.info("APScheduler 已成功启动。") - - # 4. 注册一个应用退出时的回调函数,确保调度器被安全关闭 - # 这个也只在持有锁的进程中注册 - atexit.register(lambda: scheduler.shutdown()) - -except Timeout: - # 如果获取锁超时,说明另一个进程已经启动了调度器 - app.logger.info("Could not acquire scheduler lock, another process is handling scheduling.") -finally: - # 确保在进程退出时释放锁 (尽管 with 语句通常能处理好) - # lock.release() # 在这个场景下,锁应该由持有它的进程一直持有,所以不需要手动释放 - pass - -if __name__ == '__main__': - # 确保sessions目录存在 - if not os.path.exists(SESSIONS_DIR): - os.makedirs(SESSIONS_DIR) - - app.run(debug=False, host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..5ac043b --- /dev/null +++ b/backend/README.md @@ -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 ` 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 +``` + +## 📄 许可证 + +本项目仅供学习和研究使用。 diff --git a/backend/api/admin.py b/backend/api/admin.py new file mode 100644 index 0000000..8d263af --- /dev/null +++ b/backend/api/admin.py @@ -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)}" + ) diff --git a/backend/api/auth.py b/backend/api/auth.py new file mode 100644 index 0000000..9f32bcf --- /dev/null +++ b/backend/api/auth.py @@ -0,0 +1,150 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Request, Response +from sqlalchemy.orm import Session + +from backend.models import get_db +from backend.schemas.auth import ( + QRCodeRequest, + QRCodeResponse, + QRCodeStatusResponse, + TokenVerifyRequest, + TokenVerifyResponse, + AliasLoginRequest, + AliasLoginResponse, +) +from backend.services.auth_service import AuthService + +router = APIRouter() + + +@router.post("/request_qrcode", response_model=dict, summary="请求 QQ 扫码二维码") +async def request_qrcode( + request_obj: QRCodeRequest, + req: Request, + response: Response, + db: Session = Depends(get_db) +): + """ + 请求 QQ 扫码二维码 + + - **alias**: 用户别名 + + 返回会话 ID,用于后续查询扫码状态 + """ + from backend.services.registration_manager import registration_manager + import secrets + + # 检查注册限流 Cookie + reg_cookie = req.cookies.get("reg_limit") + + if reg_cookie: + if not registration_manager.check_registration_cookie(reg_cookie): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="注册过于频繁,请 10 分钟后再试" + ) + else: + # 生成新的 Cookie + reg_cookie = secrets.token_urlsafe(16) + + # 获取客户端 IP + client_ip = req.client.host if req.client else "unknown" + + # 如果有代理,尝试从 X-Forwarded-For 获取真实 IP + forwarded_for = req.headers.get("X-Forwarded-For") + if forwarded_for: + client_ip = forwarded_for.split(",")[0].strip() + + try: + result = AuthService.request_qrcode(request_obj.alias, client_ip, db) + + # 设置限流 Cookie(10 分钟) + response.set_cookie( + key="reg_limit", + value=reg_cookie, + max_age=600, # 10 分钟 + httponly=True, + samesite="lax" + ) + + return result + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"创建扫码会话失败: {str(e)}" + ) + + +@router.get("/qrcode_status/{session_id}", response_model=dict, summary="检查二维码扫描状态") +async def get_qrcode_status( + session_id: str, + db: Session = Depends(get_db) +): + """ + 检查二维码扫描状态 + + - **session_id**: 会话 ID + + 状态说明: + - pending: 正在初始化 + - waiting_scan: 等待扫描(包含二维码图片 Base64) + - success: 扫描成功(包含 user_id 和 authorization) + - error: 发生错误 + """ + try: + result = AuthService.get_qrcode_status(session_id, db) + return result + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"查询扫码状态失败: {str(e)}" + ) + + +@router.post("/verify_token", response_model=dict, summary="验证 Token 有效性") +async def verify_token( + request: TokenVerifyRequest, + db: Session = Depends(get_db) +): + """ + 验证 Token 有效性 + + - **authorization**: Token(可带或不带 "Bearer " 前缀) + + 返回 Token 是否有效以及相关信息 + """ + try: + result = AuthService.verify_token(request.authorization, db) + return result + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"验证 Token 失败: {str(e)}" + ) + + +@router.post("/alias_login", response_model=dict, summary="别名+密码登录") +async def alias_login( + request: AliasLoginRequest, + db: Session = Depends(get_db) +): + """ + 别名+密码登录(仅限已设置密码的用户) + + - **alias**: 用户别名 + - **password**: 密码 + + 返回登录结果,成功时包含 user_id 和 authorization + + 注意: + - 用户必须已设置密码才能使用此方式登录 + - Token 必须仍然有效(未过期) + - 如果 Token 已过期,请使用扫码登录重新获取 + """ + try: + result = AuthService.alias_login(request.alias, request.password, db) + return result + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"别名登录失败: {str(e)}" + ) diff --git a/backend/api/check_in.py b/backend/api/check_in.py new file mode 100644 index 0000000..963d402 --- /dev/null +++ b/backend/api/check_in.py @@ -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)}" + ) diff --git a/backend/api/tasks.py b/backend/api/tasks.py new file mode 100644 index 0000000..10cd494 --- /dev/null +++ b/backend/api/tasks.py @@ -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) diff --git a/backend/api/templates.py b/backend/api/templates.py new file mode 100644 index 0000000..58c20e0 --- /dev/null +++ b/backend/api/templates.py @@ -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 diff --git a/backend/api/users.py b/backend/api/users.py new file mode 100644 index 0000000..ab861f5 --- /dev/null +++ b/backend/api/users.py @@ -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)}" + ) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..078c4d4 --- /dev/null +++ b/backend/config.py @@ -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() diff --git a/backend/dependencies.py b/backend/dependencies.py new file mode 100644 index 0000000..c7e6af9 --- /dev/null +++ b/backend/dependencies.py @@ -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 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..9426c67 --- /dev/null +++ b/backend/main.py @@ -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", + ) diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..ae72d2f --- /dev/null +++ b/backend/models/__init__.py @@ -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"] diff --git a/backend/models/check_in_record.py b/backend/models/check_in_record.py new file mode 100644 index 0000000..3bc384d --- /dev/null +++ b/backend/models/check_in_record.py @@ -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"" diff --git a/backend/models/check_in_task.py b/backend/models/check_in_task.py new file mode 100644 index 0000000..e0ea1ce --- /dev/null +++ b/backend/models/check_in_task.py @@ -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"" + + @property + def is_scheduled_enabled(self) -> bool: + """判断是否启用了自动打卡(is_active 为 True 且 cron_expression 不为空)""" + return bool(self.is_active) and bool(self.cron_expression) diff --git a/backend/models/database.py b/backend/models/database.py new file mode 100644 index 0000000..95f7587 --- /dev/null +++ b/backend/models/database.py @@ -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) diff --git a/backend/models/database.py.backup b/backend/models/database.py.backup new file mode 100644 index 0000000..ee2f285 --- /dev/null +++ b/backend/models/database.py.backup @@ -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) diff --git a/backend/models/task_template.py b/backend/models/task_template.py new file mode 100644 index 0000000..d5ab98b --- /dev/null +++ b/backend/models/task_template.py @@ -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"" diff --git a/backend/models/user.py b/backend/models/user.py new file mode 100644 index 0000000..c97d225 --- /dev/null +++ b/backend/models/user.py @@ -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"" + + @property + def is_admin(self) -> bool: + """判断是否为管理员""" + return self.role == "admin" diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..254aa7d --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py new file mode 100644 index 0000000..0f85b9f --- /dev/null +++ b/backend/schemas/__init__.py @@ -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", +] diff --git a/backend/schemas/auth.py b/backend/schemas/auth.py new file mode 100644 index 0000000..dbab7d1 --- /dev/null +++ b/backend/schemas/auth.py @@ -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="用户别名") diff --git a/backend/schemas/check_in.py b/backend/schemas/check_in.py new file mode 100644 index 0000000..162a92c --- /dev/null +++ b/backend/schemas/check_in.py @@ -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 diff --git a/backend/schemas/task.py b/backend/schemas/task.py new file mode 100644 index 0000000..2cd36b3 --- /dev/null +++ b/backend/schemas/task.py @@ -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 diff --git a/backend/schemas/template.py b/backend/schemas/template.py new file mode 100644 index 0000000..30ac8bf --- /dev/null +++ b/backend/schemas/template.py @@ -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="字段配置(用于前端渲染表单)") diff --git a/backend/schemas/user.py b/backend/schemas/user.py new file mode 100644 index 0000000..02c82a1 --- /dev/null +++ b/backend/schemas/user.py @@ -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分钟内) diff --git a/backend/scripts/create_admin.py b/backend/scripts/create_admin.py new file mode 100644 index 0000000..b11872a --- /dev/null +++ b/backend/scripts/create_admin.py @@ -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() diff --git a/backend/scripts/migrate_add_parent_id_to_templates.py b/backend/scripts/migrate_add_parent_id_to_templates.py new file mode 100644 index 0000000..bba05e8 --- /dev/null +++ b/backend/scripts/migrate_add_parent_id_to_templates.py @@ -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() diff --git a/backend/scripts/migrate_add_payload_config.py b/backend/scripts/migrate_add_payload_config.py new file mode 100644 index 0000000..b4399ba --- /dev/null +++ b/backend/scripts/migrate_add_payload_config.py @@ -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) diff --git a/backend/scripts/migrate_remove_old_columns.py b/backend/scripts/migrate_remove_old_columns.py new file mode 100644 index 0000000..ef75818 --- /dev/null +++ b/backend/scripts/migrate_remove_old_columns.py @@ -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) diff --git a/backend/services/admin_service.py b/backend/services/admin_service.py new file mode 100644 index 0000000..4a7a221 --- /dev/null +++ b/backend/services/admin_service.py @@ -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 diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py new file mode 100644 index 0000000..3ab1914 --- /dev/null +++ b/backend/services/auth_service.py @@ -0,0 +1,477 @@ +import uuid +import logging +import threading +import jwt +import bcrypt +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from urllib.parse import unquote +from sqlalchemy.orm import Session + +from backend.models import User +from backend.workers.token_refresher import get_token_headless, get_session_data +from backend.config import settings + +logger = logging.getLogger(__name__) + + +class AuthService: + """认证服务""" + + @staticmethod + def request_qrcode(alias: str, client_ip: str, db: Session) -> Dict[str, Any]: + """ + 请求 QQ 扫码二维码(支持新用户注册) + + Args: + alias: 用户别名 + client_ip: 客户端 IP 地址 + db: 数据库会话 + + Returns: + 包含 session_id 和 qrcode_base64 的字典 + """ + from backend.services.registration_manager import registration_manager + import time + + # 检查用户名是否已在数据库中存在 + existing_user = db.query(User).filter(User.alias == alias).first() + + # 生成唯一的会话 ID + session_id = str(uuid.uuid4()) + + if existing_user: + # 检查是否为空 jwt_sub(测试账号) + if not existing_user.jwt_sub or existing_user.jwt_sub == "": + logger.warning(f"用户 {alias} 是测试账号(空 jwt_sub),禁止登录") + return { + "status": "error", + "message": "此账户为测试账号,暂未绑定 QQ,无法登录" + } + + # 老用户:刷新 Token + logger.info(f"老用户 {alias} 请求刷新 Token,会话: {session_id}") + + # 在后台线程启动 Selenium,传入 jwt_sub + thread = threading.Thread( + target=get_token_headless, + args=(session_id, existing_user.jwt_sub, alias, client_ip), + daemon=True + ) + thread.start() + + else: + # 新用户:预占用户名 + if not registration_manager.reserve_alias(alias, session_id, timeout_seconds=120): + logger.warning(f"用户名 {alias} 已被预占") + return { + "status": "error", + "message": "该用户名正在被其他人注册,请稍后再试或更换用户名" + } + + logger.info(f"新用户 {alias} 请求注册,会话: {session_id},已预占用户名") + + # 在后台线程启动 Selenium,不传入 jwt_sub(新用户) + thread = threading.Thread( + target=get_token_headless, + args=(session_id, None, alias, client_ip), + daemon=True + ) + thread.start() + + # 等待二维码生成(最多等待 30 秒) + logger.info(f"等待会话 {session_id} 的二维码生成...") + max_wait_time = 30 + start_time = time.time() + + while time.time() - start_time < max_wait_time: + session_data = get_session_data(session_id) + + if session_data: + status = session_data.get("status") + + # 二维码已生成 + if status == "waiting_scan": + qr_image_data = session_data.get("qr_image_data") + if qr_image_data: + logger.info(f"会话 {session_id} 的二维码已生成") + return { + "session_id": session_id, + "qrcode_base64": qr_image_data + } + + # 如果已经失败,直接返回错误 + elif status == "failed": + error_msg = session_data.get("message", "生成二维码失败") + logger.error(f"会话 {session_id} 生成二维码失败: {error_msg}") + return { + "status": "error", + "message": error_msg + } + + # 每 0.5 秒检查一次 + time.sleep(0.5) + + # 超时 + logger.error(f"会话 {session_id} 等待二维码生成超时({max_wait_time}秒)") + return { + "status": "error", + "message": f"生成二维码超时,请重试" + } + + @staticmethod + def get_qrcode_status(session_id: str, db: Session) -> Dict[str, Any]: + """ + 检查二维码扫描状态 + + Args: + session_id: 会话 ID + db: 数据库会话 + + Returns: + 包含状态信息的字典 + """ + session_data = get_session_data(session_id) + + if not session_data: + return { + "status": "pending", + "message": "会话不存在或正在初始化" + } + + status = session_data.get("status") + jwt_sub = session_data.get("jwt_sub") # 使用 jwt_sub 而非 signature + + if status == "waiting_scan": + return { + "status": "waiting_scan", + "message": "请使用手机 QQ 扫描二维码", + "qrcode_image": session_data.get("qr_image_data") + } + + elif status == "success": + token = session_data.get("token") + alias = session_data.get("alias") # 新增:从 session 中获取 alias + + # 解析 JWT Token 获取 jwt_exp 和 jwt_sub + jwt_exp = "0" + jwt_sub = "" + + if not token: + logger.error("Token 为空") + return { + "status": "error", + "message": "Token 为空" + } + + try: + # 清洗 Token:URL 解码 + 去除 Bearer 前缀(参考 v1 实现) + pure_token = unquote(token) # URL 解码 + if pure_token.lower().startswith('bearer '): + pure_token = pure_token[7:] # 去除 "Bearer " 前缀 + + decoded = jwt.decode(pure_token, options={"verify_signature": False}) + jwt_exp = str(decoded.get("exp", 0)) + jwt_sub = decoded.get("sub", "") + logger.info(f"成功解析 JWT for sub={jwt_sub}, exp={jwt_exp}") + except Exception as e: + logger.error(f"解析 JWT Token 失败: {e}") + return { + "status": "error", + "message": f"Token 解析失败: {str(e)}" + } + + # 查找用户(通过 jwt_sub) + user = db.query(User).filter(User.jwt_sub == jwt_sub).first() + + if user: + # 老用户:更新 Token(存储清理后的 token) + # 注意:如果通过别名登录,需要验证 jwt_sub 是否匹配 + if alias and alias == user.alias: + # 用户使用别名登录,验证 jwt_sub 是否一致 + # 如果用户之前的 jwt_sub 不为空且与当前不一致,说明QQ号被换绑了 + existing_jwt_sub = getattr(user, 'jwt_sub', '') + if isinstance(existing_jwt_sub, str) and existing_jwt_sub.strip() and existing_jwt_sub != jwt_sub: + logger.warning(f"⚠️ 用户 {user.alias} 的 jwt_sub 不匹配!数据库: {existing_jwt_sub}, 当前: {jwt_sub}") + return { + "status": "error", + "message": "QQ账号不匹配,请使用正确的QQ号扫码登录" + } + + user.authorization = pure_token # 存储清理后的 token + user.jwt_exp = jwt_exp + user.updated_at = datetime.now() + db.commit() + db.refresh(user) + + logger.info(f"更新老用户 {user.alias} 的 Token") + + return { + "status": "success", + "message": "登录成功", + "token": pure_token, # 返回清理后的 token + "user": { + "id": user.id, + "alias": user.alias, + "role": user.role, + "is_approved": user.is_approved, + "jwt_sub": user.jwt_sub + }, + "is_new_user": False + } + + else: + # 新用户:创建账户 + from backend.services.registration_manager import registration_manager + + # 验证用户名是否被预占 + if not alias or not registration_manager.is_alias_reserved(alias): + logger.error(f"新用户注册失败:用户名 {alias} 未预占或已过期") + return { + "status": "error", + "message": "注册失败:会话已过期,请重新扫码" + } + + # 检查用户名是否已被其他人注册(防止竞态) + existing_user_by_alias = db.query(User).filter(User.alias == alias).first() + if existing_user_by_alias: + registration_manager.release_alias(alias) + logger.error(f"新用户注册失败:用户名 {alias} 已被占用") + return { + "status": "error", + "message": "注册失败:用户名已被占用,请更换用户名" + } + + # 创建新用户(待审批状态) + client_ip = session_data.get("client_ip", "") + new_user = User( + jwt_sub=jwt_sub, + alias=alias, + authorization=pure_token, # 存储清理后的 token + jwt_exp=jwt_exp, + role="user", + is_approved=False, # 待审批 + registered_ip=client_ip + ) + + db.add(new_user) + db.commit() + db.refresh(new_user) + + # 释放用户名预占 + registration_manager.release_alias(alias) + + logger.info(f"✅ 新用户 {alias} 注册成功(待审批),ID: {new_user.id}") + + # 发送邮件通知管理员 + try: + from backend.services.email_service import EmailService + EmailService.notify_new_user_registration(new_user, db) + except Exception as e: + logger.error(f"发送注册通知邮件失败: {e}") + + return { + "status": "success", + "message": "注册成功,请等待管理员审批(24小时内)", + "token": pure_token, # 返回清理后的 token + "user": { + "id": new_user.id, + "alias": new_user.alias, + "role": new_user.role, + "is_approved": new_user.is_approved, + "jwt_sub": new_user.jwt_sub + }, + "is_new_user": True + } + + elif status == "error": + return { + "status": "error", + "message": session_data.get("message", "未知错误") + } + + else: + return { + "status": "pending", + "message": "正在初始化..." + } + + @staticmethod + def verify_token(authorization: str, db: Session) -> Dict[str, Any]: + """ + 验证 Token 有效性 + + Args: + authorization: Token + db: 数据库会话 + + Returns: + 包含验证结果的字典 + """ + # 移除 "Bearer " 前缀 + token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization + + # 从数据库查询用户 + user = db.query(User).filter(User.authorization == token).first() + + if not user: + return { + "is_valid": False, + "message": "Token 无效" + } + + # 检查 Token 是否过期 + if user.jwt_exp and user.jwt_exp != "0": + try: + exp_timestamp = int(user.jwt_exp) + current_timestamp = int(datetime.now().timestamp()) + + if current_timestamp > exp_timestamp: + return { + "is_valid": False, + "message": "Token 已过期", + "user_id": user.id + } + + # 计算剩余天数 + days_until_expiry = (exp_timestamp - current_timestamp) // 86400 + + return { + "is_valid": True, + "message": "Token 有效", + "user_id": user.id, + "days_until_expiry": days_until_expiry + } + + except ValueError: + logger.error(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}") + + return { + "is_valid": True, + "message": "Token 有效", + "user_id": user.id + } + + @staticmethod + def alias_login(alias: str, password: str, db: Session) -> Dict[str, Any]: + """ + 别名+密码登录 + + Args: + alias: 用户别名 + password: 密码 + db: 数据库会话 + + Returns: + 包含登录结果的字典 + """ + # 查找用户 + user = db.query(User).filter(User.alias == alias).first() + + if not user: + logger.warning(f"别名登录失败:用户 {alias} 不存在") + return { + "success": False, + "message": "用户名或密码错误" + } + + # 检查用户是否设置了密码 + if not user.password_hash: + logger.warning(f"别名登录失败:用户 {alias} 未设置密码") + return { + "success": False, + "message": "该用户未设置密码,请使用扫码登录" + } + + # 验证密码 + try: + password_bytes = password.encode('utf-8') + hash_bytes = user.password_hash.encode('utf-8') + + if not bcrypt.checkpw(password_bytes, hash_bytes): + logger.warning(f"别名登录失败:用户 {alias} 密码错误") + return { + "success": False, + "message": "用户名或密码错误" + } + except Exception as e: + logger.error(f"密码验证异常:{e}") + return { + "success": False, + "message": "登录失败,请稍后重试" + } + + # 检查 Token 状态(仅作提示,不阻止登录) + token_warning = None + + if not user.authorization or user.jwt_exp == "0": + logger.info(f"用户 {alias} Token 无效,允许密码登录但需提示用户更新") + token_warning = "token_invalid" + else: + # 检查 Token 是否过期 + try: + exp_timestamp = int(user.jwt_exp) + current_timestamp = int(datetime.now().timestamp()) + + if current_timestamp > exp_timestamp: + logger.info(f"用户 {alias} Token 已过期,允许密码登录但需提示用户更新") + token_warning = "token_expired" + except ValueError: + logger.error(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}") + + # 登录成功 + logger.info(f"✅ 用户 {alias} (ID: {user.id}) 别名登录成功") + + result = { + "success": True, + "message": "登录成功", + "user_id": user.id, + "authorization": user.authorization, + "alias": user.alias + } + + # 如果 Token 有问题,添加警告信息 + if token_warning: + result["token_warning"] = token_warning + if token_warning == "token_invalid": + result["warning_message"] = "登录成功,但检测到登录凭证无效,部分功能可能受限,建议扫码更新" + elif token_warning == "token_expired": + result["warning_message"] = "登录成功,但检测到登录凭证已过期,部分功能可能受限,建议扫码更新" + + return result + + @staticmethod + def hash_password(password: str) -> str: + """ + 使用 bcrypt 加密密码 + + Args: + password: 明文密码 + + Returns: + 加密后的密码哈希 + """ + password_bytes = password.encode('utf-8') + salt = bcrypt.gensalt() + hash_bytes = bcrypt.hashpw(password_bytes, salt) + return hash_bytes.decode('utf-8') + + @staticmethod + def verify_password(password: str, password_hash: str) -> bool: + """ + 验证密码 + + Args: + password: 明文密码 + password_hash: 密码哈希 + + Returns: + 密码是否正确 + """ + try: + password_bytes = password.encode('utf-8') + hash_bytes = password_hash.encode('utf-8') + return bcrypt.checkpw(password_bytes, hash_bytes) + except Exception as e: + logger.error(f"密码验证异常:{e}") + return False diff --git a/backend/services/check_in_service.py b/backend/services/check_in_service.py new file mode 100644 index 0000000..0649721 --- /dev/null +++ b/backend/services/check_in_service.py @@ -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 diff --git a/backend/services/email_service.py b/backend/services/email_service.py new file mode 100644 index 0000000..9531ac6 --- /dev/null +++ b/backend/services/email_service.py @@ -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""" + + + + + + + +
+
+

🔔 新用户注册通知

+
+
+

尊敬的管理员,

+

有新用户注册了接龙自动打卡系统,请及时审批。

+ + + + + + + + + + + + + + + + + + +
用户名{user.alias}
用户 ID{user.id}
注册时间{user.created_at.strftime('%Y-%m-%d %H:%M:%S') if user.created_at else '未知'}
注册 IP{user.registered_ip or '未记录'}
+ +
+ ⚠️ 重要提示: +

该用户需要在 24 小时内通过审批,否则账户将被自动删除。

+

请登录管理后台进行审批操作。

+
+ +

登录地址:http://localhost:5173/admin/users

+
+ +
+ + + """ + + 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""" + + + + + + + +
+
+

打卡通知 {status_text}

+
+
+

您好,{user.alias}!

+

您的接龙自动打卡任务已执行。

+ + + + + + + + + + + + + + + {f'' if message else ''} +
执行时间{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
任务 ID{task_info.get('thread_id', '未知')}
打卡状态{status_text}
详细信息{message}
+ +

如有问题,请及时检查您的打卡配置。

+
+ +
+ + + """ + + return EmailService.send_email([user.email], subject, body_html) diff --git a/backend/services/registration_manager.py b/backend/services/registration_manager.py new file mode 100644 index 0000000..d2f18e8 --- /dev/null +++ b/backend/services/registration_manager.py @@ -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() diff --git a/backend/services/scheduler_service.py b/backend/services/scheduler_service.py new file mode 100644 index 0000000..3ae4efb --- /dev/null +++ b/backend/services/scheduler_service.py @@ -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}") diff --git a/backend/services/task_service.py b/backend/services/task_service.py new file mode 100644 index 0000000..1aca8d6 --- /dev/null +++ b/backend/services/task_service.py @@ -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)}") diff --git a/backend/services/template_service.py b/backend/services/template_service.py new file mode 100644 index 0000000..fa067d5 --- /dev/null +++ b/backend/services/template_service.py @@ -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)}" + ) diff --git a/backend/services/user_service.py b/backend/services/user_service.py new file mode 100644 index 0000000..42766f9 --- /dev/null +++ b/backend/services/user_service.py @@ -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() diff --git a/backend/workers/check_in_worker.py b/backend/workers/check_in_worker.py new file mode 100644 index 0000000..48c57b5 --- /dev/null +++ b/backend/workers/check_in_worker.py @@ -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) + } diff --git a/email_notifier.py b/backend/workers/email_notifier.py similarity index 50% rename from email_notifier.py rename to backend/workers/email_notifier.py index d30d247..5bb5c0c 100644 --- a/email_notifier.py +++ b/backend/workers/email_notifier.py @@ -2,13 +2,13 @@ import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart import time -import csv -import os +import logging import configparser +from pathlib import Path -from shared_config import CONFIG_PATH, CONFIG_FILE_LOCK, get_logger, CONFIG_INI_PATH +from backend.config import settings -logger = get_logger(__name__) +logger = logging.getLogger(__name__) # --- 邮件模板 --- @@ -30,7 +30,7 @@ EXPIRATION_HTML_TEMPLATE = """

注意!

{name},请注意!

-

您的 token 已经到期,请前往 http://localhost:5000 重新刷新您的 token,否则您的自动打卡功能将会失效。

+

您的 token 已经到期,请尽快重新刷新您的 token,否则您的自动打卡功能将会失效。

到期时间: {exp_time}

@@ -72,7 +72,7 @@ FAILURE_HTML_TEMPLATE = """ 打卡失败通知 diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..3755434 --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,73 @@ +import axios from 'axios' + +// 创建 axios 实例 +const client = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000', + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// 请求拦截器 - 添加 Token +client.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// 响应拦截器 - 统一错误处理 +client.interceptors.response.use( + (response) => { + return response.data + }, + (error) => { + if (error.response) { + // 服务器返回错误状态码 + const { status, data } = error.response + + if (status === 401) { + // Token 过期或无效,清除登录状态 + localStorage.removeItem('token') + localStorage.removeItem('user') + + // 延迟跳转,避免阻塞当前异步请求的错误处理 + setTimeout(() => { + if (window.location.pathname !== '/login') { + window.location.href = '/login' + } + }, 100) + } + + // 返回统一的错误对象 + return Promise.reject({ + status, + message: data.detail || data.message || '请求失败', + data, + }) + } else if (error.request) { + // 请求已发出但没有收到响应(超时或网络错误) + return Promise.reject({ + status: 0, + message: error.code === 'ECONNABORTED' ? '请求超时,请稍后重试' : '网络错误,请检查您的网络连接', + data: null, + }) + } else { + // 发生了触发请求错误的问题 + return Promise.reject({ + status: 0, + message: error.message || '请求配置错误', + data: null, + }) + } + } +) + +export default client diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..42484f0 --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,254 @@ +import client from './client' + +/** + * 认证 API + */ +export const authAPI = { + // 请求 QR 码 + requestQRCode: (alias) => { + return client.post('/api/auth/request_qrcode', { alias }) + }, + + // 查询扫码状态 + getQRCodeStatus: (sessionId) => { + return client.get(`/api/auth/qrcode_status/${sessionId}`) + }, + + // 别名+密码登录 + aliasLogin: (alias, password) => { + return client.post('/api/auth/alias_login', { alias, password }) + }, + + // 验证 Token + verifyToken: (token) => { + return client.post('/api/auth/verify_token', { token }) + }, +} + +/** + * 用户 API + */ +export const userAPI = { + // 获取当前用户信息 + getCurrentUser: () => { + return client.get('/api/users/me') + }, + + // 获取当前用户审批状态 + getUserStatus: () => { + return client.get('/api/users/me/status') + }, + + // 获取当前用户 Token 状态 + getTokenStatus: () => { + return client.get('/api/users/me/token_status') + }, + + // 更新当前用户个人信息 + updateProfile: (profileData) => { + return client.put('/api/users/me/profile', profileData) + }, + + // 创建用户(管理员) + createUser: (userData) => { + return client.post('/api/users', userData) + }, + + // 获取所有用户(管理员) + getUsers: (params = {}) => { + return client.get('/api/users', { params }) + }, + + // 获取指定用户 + getUser: (userId) => { + return client.get(`/api/users/${userId}`) + }, + + // 更新用户 + updateUser: (userId, userData) => { + return client.put(`/api/users/${userId}`, userData) + }, + + // 删除用户 + deleteUser: (userId) => { + return client.delete(`/api/users/${userId}`) + }, +} + +/** + * 任务 API (V2 新增) + */ +export const taskAPI = { + // 获取当前用户的任务列表 + getMyTasks: (params = {}) => { + return client.get('/api/tasks', { params }) + }, + + // 创建任务 + createTask: (taskData) => { + return client.post('/api/tasks', taskData) + }, + + // 获取任务详情 + getTask: (taskId) => { + return client.get(`/api/tasks/${taskId}`) + }, + + // 更新任务 + updateTask: (taskId, taskData) => { + return client.put(`/api/tasks/${taskId}`, taskData) + }, + + // 删除任务 + deleteTask: (taskId) => { + return client.delete(`/api/tasks/${taskId}`) + }, + + // 切换任务启用状态 + toggleTask: (taskId) => { + return client.post(`/api/tasks/${taskId}/toggle`) + }, + + // 手动触发任务打卡(异步,立即返回) + checkInTask: (taskId) => { + return client.post(`/api/check_in/manual/${taskId}`) + }, + + // 查询打卡记录状态 + getCheckInRecordStatus: (recordId) => { + return client.get(`/api/check_in/record/${recordId}/status`) + }, + + // 获取任务的打卡记录 + getTaskRecords: (taskId, params = {}) => { + return client.get(`/api/check_in/task/${taskId}/records`, { params }) + }, +} + +/** + * 打卡 API + */ +export const checkInAPI = { + // 手动打卡(兼容旧版,推荐使用 taskAPI.checkInTask) + manualCheckIn: (taskId) => { + // 打卡操作耗时较长,设置 120 秒超时 + return client.post(`/api/check_in/manual/${taskId}`, {}, { + timeout: 120000 // 120 秒 + }) + }, + + // 获取任务打卡记录(兼容旧版,推荐使用 taskAPI.getTaskRecords) + getMyRecords: (params = {}) => { + return client.get('/api/check_in/my-records', { params }) + }, + + // 获取所有打卡记录(管理员) + getAllRecords: (params = {}) => { + return client.get('/api/check_in/records', { params }) + }, + + // 统计打卡记录数 + getRecordsCount: (params = {}) => { + return client.get('/api/check_in/records/count', { params }) + }, +} + +/** + * 管理员 API + */ +export const adminAPI = { + // 获取待审批用户 + getPendingUsers: () => { + return client.get('/api/admin/users/pending') + }, + + // 审批通过用户 + approveUser: (userId) => { + return client.post(`/api/admin/users/${userId}/approve`) + }, + + // 拒绝用户 + rejectUser: (userId) => { + return client.delete(`/api/admin/users/${userId}/reject`) + }, + + // 批量启用/禁用任务(V2 更新) + batchToggleTasks: (taskIds, isActive) => { + return client.post('/api/admin/batch_toggle_tasks', { + task_ids: taskIds, + is_active: isActive + }) + }, + + // 批量触发打卡(V2 更新) + batchCheckIn: (taskIds) => { + return client.post('/api/admin/batch_check_in', { + task_ids: taskIds + }) + }, + + // 查看系统日志 + getLogs: (params = {}) => { + return client.get('/api/admin/logs', { params }) + }, + + // 系统统计信息 + getStats: () => { + return client.get('/api/admin/stats') + }, +} + +/** + * 模板 API + */ +export const templateAPI = { + // 获取所有模板列表 + getTemplates: (params = {}) => { + return client.get('/api/templates', { params }) + }, + + // 获取启用的模板列表 + getActiveTemplates: (params = {}) => { + return client.get('/api/templates/active', { params }) + }, + + // 获取单个模板详情 + getTemplate: (templateId) => { + return client.get(`/api/templates/${templateId}`) + }, + + // 预览模板生成的 payload + previewTemplate: (templateId) => { + return client.get(`/api/templates/${templateId}/preview`) + }, + + // 创建模板(管理员) + createTemplate: (templateData) => { + return client.post('/api/templates', templateData) + }, + + // 更新模板(管理员) + updateTemplate: (templateId, templateData) => { + return client.put(`/api/templates/${templateId}`, templateData) + }, + + // 删除模板(管理员) + deleteTemplate: (templateId) => { + return client.delete(`/api/templates/${templateId}`) + }, + + // 从模板创建任务 + createTaskFromTemplate: (requestData) => { + return client.post('/api/templates/create-task', requestData) + }, +} + +// 导出所有 API +export default { + auth: authAPI, + user: userAPI, + task: taskAPI, // V2 新增 + checkIn: checkInAPI, + admin: adminAPI, + template: templateAPI, // V2.2 新增 +} diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/CrontabEditor.vue b/frontend/src/components/CrontabEditor.vue new file mode 100644 index 0000000..8cfc17f --- /dev/null +++ b/frontend/src/components/CrontabEditor.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/frontend/src/components/FieldConfigEditor.vue b/frontend/src/components/FieldConfigEditor.vue new file mode 100644 index 0000000..d41b93f --- /dev/null +++ b/frontend/src/components/FieldConfigEditor.vue @@ -0,0 +1,294 @@ + + + + + diff --git a/frontend/src/components/FieldTreeNode.vue b/frontend/src/components/FieldTreeNode.vue new file mode 100644 index 0000000..8f61719 --- /dev/null +++ b/frontend/src/components/FieldTreeNode.vue @@ -0,0 +1,497 @@ + + + + + diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue new file mode 100644 index 0000000..546ebbc --- /dev/null +++ b/frontend/src/components/HelloWorld.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue new file mode 100644 index 0000000..a2fc091 --- /dev/null +++ b/frontend/src/components/Layout.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/frontend/src/components/Navbar.vue b/frontend/src/components/Navbar.vue new file mode 100644 index 0000000..a16ddbc --- /dev/null +++ b/frontend/src/components/Navbar.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/frontend/src/components/Navbar.vue.backup b/frontend/src/components/Navbar.vue.backup new file mode 100644 index 0000000..309fc0d --- /dev/null +++ b/frontend/src/components/Navbar.vue.backup @@ -0,0 +1,97 @@ + + + + + diff --git a/frontend/src/components/QRCodeModal.vue b/frontend/src/components/QRCodeModal.vue new file mode 100644 index 0000000..e587740 --- /dev/null +++ b/frontend/src/components/QRCodeModal.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..4353cb7 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,54 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import { + User, + Key, + Calendar, + Refresh, + Document, + List, + Plus, + UserFilled, + DataAnalysis, + Loading, + SuccessFilled, + WarningFilled, + CircleCloseFilled +} from '@element-plus/icons-vue' + +import App from './App.vue' +import router from './router' +import './style.css' + +const app = createApp(App) +const pinia = createPinia() + +// 按需注册 Element Plus 图标(仅注册使用的13个) +const icons = { + User, + Key, + Calendar, + Refresh, + Document, + List, + Plus, + UserFilled, + DataAnalysis, + Loading, + SuccessFilled, + WarningFilled, + CircleCloseFilled +} + +for (const [key, component] of Object.entries(icons)) { + app.component(key, component) +} + +app.use(pinia) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) + +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..5b0d81d --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,163 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/LoginView.vue'), + meta: { requiresAuth: false, title: '登录' }, + }, + { + path: '/', + redirect: '/dashboard', + }, + { + path: '/pending-approval', + name: 'PendingApproval', + component: () => import('@/views/PendingApprovalView.vue'), + meta: { requiresAuth: true, title: '等待审批' }, + }, + { + path: '/dashboard', + name: 'Dashboard', + component: () => import('@/views/DashboardView.vue'), + meta: { requiresAuth: true, title: '我的仪表盘' }, + }, + { + path: '/tasks', + name: 'Tasks', + component: () => import('@/views/TasksView.vue'), + meta: { requiresAuth: true, title: '任务管理' }, + }, + { + path: '/tasks/:taskId/records', + name: 'TaskRecords', + component: () => import('@/views/TaskRecordsView.vue'), + meta: { requiresAuth: true, title: '任务打卡记录' }, + }, + { + path: '/records', + name: 'Records', + component: () => import('@/views/RecordsView.vue'), + meta: { requiresAuth: true, title: '打卡记录' }, + }, + { + path: '/settings', + name: 'Settings', + component: () => import('@/views/SettingsView.vue'), + meta: { requiresAuth: true, title: '个人设置' }, + }, + { + path: '/admin', + meta: { requiresAuth: true, requiresAdmin: true }, + children: [ + { + path: 'users', + name: 'AdminUsers', + component: () => import('@/views/admin/UsersView.vue'), + meta: { requiresAuth: true, requiresAdmin: true, title: '用户管理' }, + }, + { + path: 'records', + name: 'AdminRecords', + component: () => import('@/views/admin/RecordsView.vue'), + meta: { requiresAuth: true, requiresAdmin: true, title: '打卡记录' }, + }, + { + path: 'logs', + name: 'AdminLogs', + component: () => import('@/views/admin/LogsView.vue'), + meta: { requiresAuth: true, requiresAdmin: true, title: '系统日志' }, + }, + { + path: 'stats', + name: 'AdminStats', + component: () => import('@/views/admin/StatsView.vue'), + meta: { requiresAuth: true, requiresAdmin: true, title: '统计信息' }, + }, + { + path: 'templates', + name: 'AdminTemplates', + component: () => import('@/views/admin/TemplatesView.vue'), + meta: { requiresAuth: true, requiresAdmin: true, title: '模板管理' }, + }, + ], + }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('@/views/NotFoundView.vue'), + meta: { requiresAuth: false, title: '页面未找到' }, + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +// 全局前置守卫 +router.beforeEach(async (to, from, next) => { + const authStore = useAuthStore() + + // 设置页面标题 + document.title = to.meta.title ? `${to.meta.title} - 接龙自动打卡系统` : '接龙自动打卡系统' + + // 检查是否需要认证 + if (to.meta.requiresAuth) { + if (!authStore.isAuthenticated) { + // 未登录,重定向到登录页 + next({ name: 'Login', query: { redirect: to.fullPath } }) + return + } + + // 检查用户审批状态(除了待审批页面本身) + if (to.name !== 'PendingApproval') { + try { + const { userAPI } = await import('@/api') + const status = await userAPI.getUserStatus() + + if (!status.is_approved) { + // 未审批用户只能访问待审批页面 + next({ name: 'PendingApproval' }) + return + } + } catch (error) { + console.error('检查审批状态失败:', error) + // 如果检查失败,允许继续访问(避免阻塞正常用户) + } + } else { + // 访问待审批页面时,检查是否已审批 + try { + const { userAPI } = await import('@/api') + const status = await userAPI.getUserStatus() + + if (status.is_approved) { + // 已审批用户不能访问待审批页面 + next({ name: 'Dashboard' }) + return + } + } catch (error) { + console.error('检查审批状态失败:', error) + } + } + + // 检查是否需要管理员权限 + if (to.meta.requiresAdmin && !authStore.isAdmin) { + // 非管理员,重定向到仪表盘 + next({ name: 'Dashboard' }) + return + } + } else { + // 不需要认证的页面,如果已登录则重定向到仪表盘 + if (to.name === 'Login' && authStore.isAuthenticated) { + next({ name: 'Dashboard' }) + return + } + } + + next() +}) + +export default router diff --git a/frontend/src/stores/admin.js b/frontend/src/stores/admin.js new file mode 100644 index 0000000..81a835b --- /dev/null +++ b/frontend/src/stores/admin.js @@ -0,0 +1,72 @@ +import { defineStore } from 'pinia' +import { adminAPI } from '@/api' + +export const useAdminStore = defineStore('admin', { + state: () => ({ + stats: null, // 系统统计信息 + logs: [], + logsTotal: 0, + loading: false, + }), + + getters: { + totalUsers: (state) => state.stats?.users?.total || 0, + activeUsers: (state) => { + // Active users = 已审批的用户(is_approved=true) + return state.stats?.users?.active || 0 + }, + totalRecords: (state) => state.stats?.check_in_records?.total || 0, + todayRecords: (state) => state.stats?.check_in_records?.today || 0, + }, + + actions: { + // 获取系统统计信息 + async fetchStats() { + this.loading = true + try { + const stats = await adminAPI.getStats() + this.stats = stats + return stats + } catch (error) { + throw new Error(error.message || '获取统计信息失败') + } finally { + this.loading = false + } + }, + + // 批量启用/禁用用户 + async batchToggleActive(userIds, isActive) { + try { + const result = await adminAPI.batchToggleActive(userIds, isActive) + return result + } catch (error) { + throw new Error(error.message || '批量操作失败') + } + }, + + // 批量触发打卡 + async batchCheckIn(userIds) { + try { + const result = await adminAPI.batchCheckIn(userIds) + return result + } catch (error) { + throw new Error(error.message || '批量打卡失败') + } + }, + + // 获取系统日志 + async fetchLogs(params = {}) { + this.loading = true + try { + const data = await adminAPI.getLogs(params) + this.logs = data.logs || data + this.logsTotal = data.total || this.logs.length + return data + } catch (error) { + throw new Error(error.message || '获取日志失败') + } finally { + this.loading = false + } + }, + }, +}) diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 0000000..48d0b6c --- /dev/null +++ b/frontend/src/stores/auth.js @@ -0,0 +1,124 @@ +import { defineStore } from 'pinia' +import { authAPI, userAPI } from '@/api' + +export const useAuthStore = defineStore('auth', { + state: () => { + // 安全地解析 localStorage 中的用户数据 + let user = null + try { + const userStr = localStorage.getItem('user') + if (userStr && userStr !== 'undefined' && userStr !== 'null') { + user = JSON.parse(userStr) + } + } catch (e) { + console.warn('Failed to parse user from localStorage:', e) + localStorage.removeItem('user') + } + + return { + token: localStorage.getItem('token') || null, + user, + } + }, + + getters: { + // 将 isAuthenticated 改为 getter,这样它会实时反应 state.token 的变化 + isAuthenticated: (state) => !!state.token, + isAdmin: (state) => state.user?.role === 'admin', + }, + + actions: { + // 设置认证信息 + setAuth(token, user) { + // 清理 token:移除 URL 编码的 Bearer 前缀 + let cleanToken = token + if (cleanToken) { + // URL 解码 + cleanToken = decodeURIComponent(cleanToken) + // 移除 Bearer 前缀(如果存在) + if (cleanToken.toLowerCase().startsWith('bearer ')) { + cleanToken = cleanToken.substring(7) + } + } + + this.token = cleanToken + this.user = user + + localStorage.setItem('token', cleanToken) + localStorage.setItem('user', JSON.stringify(user)) + }, + + // 清除认证信息 + clearAuth() { + this.token = null + this.user = null + + localStorage.removeItem('token') + localStorage.removeItem('user') + }, + + // QR 码登录流程 + async loginWithQRCode(alias) { + try { + // 1. 请求 QR 码 + const qrData = await authAPI.requestQRCode(alias) + const { session_id, qrcode_base64 } = qrData + + // 2. 返回 session_id 和 qrcode,由组件处理轮询 + return { session_id, qrcode_base64 } + } catch (error) { + throw new Error(error.message || '请求二维码失败') + } + }, + + // 检查扫码状态 + async checkQRCodeStatus(sessionId) { + try { + const result = await authAPI.getQRCodeStatus(sessionId) + + if (result.status === 'success') { + // 扫码成功,保存 Token 和用户信息 + this.setAuth(result.token, result.user) + return { success: true, user: result.user } + } else if (result.status === 'failed') { + return { success: false, message: result.message } + } else { + // pending 或 expired + return { success: false, status: result.status } + } + } catch (error) { + throw new Error(error.message || '检查扫码状态失败') + } + }, + + // 验证 Token + async verifyToken(token) { + try { + const userData = await authAPI.verifyToken(token) + this.setAuth(token, userData) + return userData + } catch (error) { + this.clearAuth() + throw new Error(error.message || 'Token 验证失败') + } + }, + + // 获取当前用户信息 + async fetchCurrentUser() { + try { + const userData = await userAPI.getCurrentUser() + // 更新本地用户信息 + this.user = userData + localStorage.setItem('user', JSON.stringify(userData)) + return userData + } catch (error) { + throw new Error(error.message || '获取用户信息失败') + } + }, + + // 登出 + logout() { + this.clearAuth() + }, + }, +}) diff --git a/frontend/src/stores/checkIn.js b/frontend/src/stores/checkIn.js new file mode 100644 index 0000000..49bd798 --- /dev/null +++ b/frontend/src/stores/checkIn.js @@ -0,0 +1,94 @@ +import { defineStore } from 'pinia' +import { checkInAPI } from '@/api' + +export const useCheckInStore = defineStore('checkIn', { + state: () => ({ + myRecords: [], + allRecords: [], // 管理员查看所有记录 + currentPage: 1, + pageSize: 20, + total: 0, + loading: false, + }), + + getters: { + todayRecords: (state) => { + const today = new Date().toISOString().split('T')[0] + return state.myRecords.filter((record) => { + const recordDate = new Date(record.check_in_time).toISOString().split('T')[0] + return recordDate === today + }) + }, + + successRate: (state) => { + if (state.myRecords.length === 0) return 0 + const successCount = state.myRecords.filter((r) => r.status === 'success').length + return ((successCount / state.myRecords.length) * 100).toFixed(2) + }, + }, + + actions: { + // 手动打卡 + async manualCheckIn() { + this.loading = true + try { + const result = await checkInAPI.manualCheckIn() + // 刷新打卡记录 + await this.fetchMyRecords() + return result + } catch (error) { + throw new Error(error.message || '打卡失败') + } finally { + this.loading = false + } + }, + + // 获取我的打卡记录 + async fetchMyRecords(params = {}) { + this.loading = true + try { + const data = await checkInAPI.getMyRecords({ + skip: (this.currentPage - 1) * this.pageSize, + limit: this.pageSize, + ...params, + }) + this.myRecords = data.records || data + this.total = data.total || this.myRecords.length + return data + } catch (error) { + throw new Error(error.message || '获取打卡记录失败') + } finally { + this.loading = false + } + }, + + // 获取所有打卡记录(管理员) + async fetchAllRecords(params = {}) { + this.loading = true + try { + const data = await checkInAPI.getAllRecords({ + skip: (this.currentPage - 1) * this.pageSize, + limit: this.pageSize, + ...params, + }) + this.allRecords = data.records || data + this.total = data.total || this.allRecords.length + return data + } catch (error) { + throw new Error(error.message || '获取打卡记录失败') + } finally { + this.loading = false + } + }, + + // 统计打卡记录 + async getRecordsCount(params = {}) { + try { + const count = await checkInAPI.getRecordsCount(params) + return count + } catch (error) { + throw new Error(error.message || '获取统计信息失败') + } + }, + }, +}) diff --git a/frontend/src/stores/task.js b/frontend/src/stores/task.js new file mode 100644 index 0000000..d14b0d4 --- /dev/null +++ b/frontend/src/stores/task.js @@ -0,0 +1,180 @@ +import { defineStore } from 'pinia' +import api from '@/api' + +export const useTaskStore = defineStore('task', { + state: () => ({ + tasks: [], // 当前用户的任务列表 + currentTask: null, // 当前选中的任务 + loading: false, + error: null, + }), + + getters: { + // 启用的任务 + activeTasks: (state) => state.tasks.filter(t => t.is_active), + + // 禁用的任务 + inactiveTasks: (state) => state.tasks.filter(t => !t.is_active), + + // 任务数量统计 + taskStats: (state) => ({ + total: state.tasks.length, + active: state.tasks.filter(t => t.is_active).length, + inactive: state.tasks.filter(t => !t.is_active).length, + }), + + // 根据 ID 获取任务 + getTaskById: (state) => (taskId) => { + return state.tasks.find(t => t.id === taskId) + }, + }, + + actions: { + // 获取当前用户的所有任务 + async fetchMyTasks(includeInactive = true) { + this.loading = true + this.error = null + try { + const tasks = await api.task.getMyTasks({ include_inactive: includeInactive }) + this.tasks = tasks + return tasks + } catch (error) { + this.error = error.message || '获取任务列表失败' + throw error + } finally { + this.loading = false + } + }, + + // 创建新任务 + async createTask(taskData) { + this.loading = true + this.error = null + try { + const newTask = await api.task.createTask(taskData) + this.tasks.unshift(newTask) // 添加到列表开头 + return newTask + } catch (error) { + // 解析后端错误信息 + let errorMsg = error.message || '创建任务失败' + + this.error = errorMsg + throw new Error(errorMsg) + } finally { + this.loading = false + } + }, + + // 更新任务 + async updateTask(taskId, taskData) { + this.loading = true + this.error = null + try { + const updatedTask = await api.task.updateTask(taskId, taskData) + const index = this.tasks.findIndex(t => t.id === taskId) + if (index !== -1) { + this.tasks[index] = updatedTask + } + return updatedTask + } catch (error) { + this.error = error.message || '更新任务失败' + throw error + } finally { + this.loading = false + } + }, + + // 删除任务 + async deleteTask(taskId) { + this.loading = true + this.error = null + try { + await api.task.deleteTask(taskId) + this.tasks = this.tasks.filter(t => t.id !== taskId) + } catch (error) { + this.error = error.message || '删除任务失败' + throw error + } finally { + this.loading = false + } + }, + + // 切换任务启用状态 + async toggleTask(taskId) { + this.loading = true + this.error = null + try { + const updatedTask = await api.task.toggleTask(taskId) + const index = this.tasks.findIndex(t => t.id === taskId) + if (index !== -1) { + this.tasks[index] = updatedTask + } + return updatedTask + } catch (error) { + this.error = error.message || '切换任务状态失败' + throw error + } finally { + this.loading = false + } + }, + + // 获取任务详情 + async fetchTask(taskId) { + this.loading = true + this.error = null + try { + const task = await api.task.getTask(taskId) + this.currentTask = task + return task + } catch (error) { + this.error = error.message || '获取任务详情失败' + throw error + } finally { + this.loading = false + } + }, + + // 手动触发任务打卡(异步方式,立即返回 record_id) + async checkInTask(taskId) { + // Don't set global loading state to avoid blocking UI during long check-in operations + this.error = null + try { + const result = await api.task.checkInTask(taskId) + return result + } catch (error) { + this.error = error.message || '打卡失败' + throw error + } + }, + + // 查询打卡记录状态 + async getCheckInRecordStatus(recordId) { + try { + const result = await api.task.getCheckInRecordStatus(recordId) + return result + } catch (error) { + throw error + } + }, + + // 获取任务的打卡记录 + async fetchTaskRecords(taskId, params = {}) { + this.loading = true + this.error = null + try { + const records = await api.task.getTaskRecords(taskId, params) + return records + } catch (error) { + this.error = error.message || '获取打卡记录失败' + throw error + } finally { + this.loading = false + } + }, + + // 清空当前任务 + clearCurrentTask() { + this.currentTask = null + }, + }, +}) diff --git a/frontend/src/stores/template.js b/frontend/src/stores/template.js new file mode 100644 index 0000000..2a4287a --- /dev/null +++ b/frontend/src/stores/template.js @@ -0,0 +1,162 @@ +import { defineStore } from 'pinia' +import { templateAPI } from '@/api' + +export const useTemplateStore = defineStore('template', { + state: () => ({ + templates: [], + currentTemplate: null, + loading: false, + error: null, + }), + + getters: { + activeTemplates: (state) => state.templates.filter((t) => t.is_active), + + getTemplateById: (state) => (id) => { + return state.templates.find((t) => t.id === id) + }, + }, + + actions: { + async fetchTemplates(isActive = null) { + this.loading = true + this.error = null + try { + const params = {} + if (isActive !== null) { + params.is_active = isActive + } + this.templates = await templateAPI.getTemplates(params) + return this.templates + } catch (error) { + this.error = error.message || '获取模板列表失败' + throw error + } finally { + this.loading = false + } + }, + + async fetchActiveTemplates() { + this.loading = true + this.error = null + try { + this.templates = await templateAPI.getActiveTemplates() + return this.templates + } catch (error) { + this.error = error.message || '获取启用模板失败' + throw error + } finally { + this.loading = false + } + }, + + async fetchTemplate(id) { + this.loading = true + this.error = null + try { + this.currentTemplate = await templateAPI.getTemplate(id) + return this.currentTemplate + } catch (error) { + this.error = error.message || '获取模板详情失败' + throw error + } finally { + this.loading = false + } + }, + + async previewTemplate(id) { + this.loading = true + this.error = null + try { + const preview = await templateAPI.previewTemplate(id) + return preview + } catch (error) { + this.error = error.message || '预览模板失败' + throw error + } finally { + this.loading = false + } + }, + + async createTemplate(templateData) { + this.loading = true + this.error = null + try { + const newTemplate = await templateAPI.createTemplate(templateData) + this.templates.unshift(newTemplate) + return newTemplate + } catch (error) { + this.error = error.message || '创建模板失败' + throw error + } finally { + this.loading = false + } + }, + + async updateTemplate(id, templateData) { + this.loading = true + this.error = null + try { + const updatedTemplate = await templateAPI.updateTemplate(id, templateData) + const index = this.templates.findIndex((t) => t.id === id) + if (index !== -1) { + this.templates[index] = updatedTemplate + } + if (this.currentTemplate && this.currentTemplate.id === id) { + this.currentTemplate = updatedTemplate + } + return updatedTemplate + } catch (error) { + this.error = error.message || '更新模板失败' + throw error + } finally { + this.loading = false + } + }, + + async deleteTemplate(id) { + this.loading = true + this.error = null + try { + await templateAPI.deleteTemplate(id) + this.templates = this.templates.filter((t) => t.id !== id) + if (this.currentTemplate && this.currentTemplate.id === id) { + this.currentTemplate = null + } + return true + } catch (error) { + this.error = error.message || '删除模板失败' + throw error + } finally { + this.loading = false + } + }, + + async createTaskFromTemplate(templateId, threadId, fieldValues, taskName = null) { + this.loading = true + this.error = null + try { + const task = await templateAPI.createTaskFromTemplate({ + template_id: templateId, + thread_id: threadId, + field_values: fieldValues, + task_name: taskName, + }) + return task + } catch (error) { + this.error = error.message || '从模板创建任务失败' + throw error + } finally { + this.loading = false + } + }, + + clearCurrentTemplate() { + this.currentTemplate = null + }, + + clearError() { + this.error = null + }, + }, +}) diff --git a/frontend/src/stores/user.js b/frontend/src/stores/user.js new file mode 100644 index 0000000..4781a22 --- /dev/null +++ b/frontend/src/stores/user.js @@ -0,0 +1,90 @@ +import { defineStore } from 'pinia' +import { userAPI } from '@/api' + +export const useUserStore = defineStore('user', { + state: () => ({ + tokenStatus: null, // Token 状态信息 + users: [], // 用户列表(管理员) + currentPage: 1, + pageSize: 20, + total: 0, + }), + + getters: { + isTokenExpiring: (state) => { + if (!state.tokenStatus) return false + return state.tokenStatus.expiring_soon || false + }, + + tokenExpireTime: (state) => { + if (!state.tokenStatus || !state.tokenStatus.expires_at) return null + return new Date(state.tokenStatus.expires_at * 1000) + }, + }, + + actions: { + // 获取 Token 状态 + async fetchTokenStatus() { + try { + const status = await userAPI.getTokenStatus() + this.tokenStatus = status + return status + } catch (error) { + throw new Error(error.message || '获取 Token 状态失败') + } + }, + + // 获取用户列表(管理员) + async fetchUsers(params = {}) { + try { + const data = await userAPI.getUsers(params) + this.users = data.users || data + this.total = data.total || this.users.length + return data + } catch (error) { + throw new Error(error.message || '获取用户列表失败') + } + }, + + // 创建用户(管理员) + async createUser(userData) { + try { + const newUser = await userAPI.createUser(userData) + // 刷新用户列表 + await this.fetchUsers() + return newUser + } catch (error) { + throw new Error(error.message || '创建用户失败') + } + }, + + // 更新用户 + async updateUser(userId, userData) { + try { + // 过滤空密码字段 + const cleanedData = { ...userData } + if (cleanedData.password === '' || cleanedData.password === null || cleanedData.password === undefined) { + delete cleanedData.password + } + + const updatedUser = await userAPI.updateUser(userId, cleanedData) + // 刷新用户列表 + await this.fetchUsers() + return updatedUser + } catch (error) { + throw new Error(error.message || '更新用户失败') + } + }, + + // 删除用户 + async deleteUser(userId) { + try { + await userAPI.deleteUser(userId) + // 刷新用户列表 + await this.fetchUsers() + } catch (error) { + throw new Error(error.message || '删除用户失败') + } + }, + }, +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..a044f71 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,155 @@ +/* TailwindCSS directives */ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Global styles */ +@layer base { + :root { + font-family: 'Inter', 'Segoe UI', 'Roboto', system-ui, -apple-system, sans-serif; + line-height: 1.6; + font-weight: 400; + color-scheme: light; + + /* Material Design 3 color tokens */ + --md-sys-color-primary: #4caf50; + --md-sys-color-on-primary: #ffffff; + --md-sys-color-secondary: #2196f3; + --md-sys-color-on-secondary: #ffffff; + --md-sys-color-surface: #fafafa; + --md-sys-color-on-surface: #1c1b1f; + --md-sys-color-background: #ffffff; + --md-sys-color-on-background: #1c1b1f; + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: linear-gradient(135deg, #f5f7fa 0%, #e8eef5 100%); + color: var(--md-sys-color-on-background); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + #app { + width: 100%; + min-height: 100vh; + } +} + +/* Custom utility classes */ +@layer components { + /* Material Design 3 Card */ + .md3-card { + @apply bg-white rounded-md3 shadow-md3-2 overflow-hidden transition-all duration-300; + } + + .md3-card:hover { + @apply shadow-md3-3; + } + + .md3-card-elevated { + @apply bg-white rounded-md3-lg shadow-md3-3; + } + + /* Material Design 3 Button */ + .md3-button { + @apply px-6 py-2.5 rounded-full font-medium transition-all duration-200; + @apply inline-flex items-center justify-center gap-2; + } + + .md3-button-filled { + @apply md3-button bg-primary-600 text-white hover:bg-primary-700 hover:shadow-md3-2; + } + + .md3-button-outlined { + @apply md3-button border-2 border-primary-600 text-primary-600 hover:bg-primary-50; + } + + .md3-button-text { + @apply md3-button text-primary-600 hover:bg-primary-50; + } + + /* Fluent Design elements */ + .fluent-card { + @apply bg-white/80 backdrop-blur-xl rounded-lg border border-gray-200/50 shadow-lg; + @apply transition-all duration-300 hover:shadow-xl hover:border-gray-300/50; + } + + .fluent-acrylic { + @apply bg-white/70 backdrop-blur-2xl backdrop-saturate-150; + } + + /* Status badges */ + .status-badge { + @apply inline-flex items-center px-3 py-1 rounded-full text-xs font-medium; + } + + .status-success { + @apply status-badge bg-green-100 text-green-800; + } + + .status-warning { + @apply status-badge bg-yellow-100 text-yellow-800; + } + + .status-error { + @apply status-badge bg-red-100 text-red-800; + } + + .status-info { + @apply status-badge bg-blue-100 text-blue-800; + } + + /* Loading skeleton */ + .skeleton { + @apply animate-pulse bg-gray-200 rounded; + } +} + +/* Custom animations */ +@layer utilities { + .transition-smooth { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + .glass-effect { + @apply bg-white/60 backdrop-blur-md backdrop-saturate-150; + } + + .text-gradient { + @apply bg-gradient-to-r from-primary-600 to-secondary-600 bg-clip-text text-transparent; + } +} + +/* Element Plus customization to work with Tailwind */ +.el-button { + @apply transition-smooth; +} + +.el-card { + @apply transition-smooth; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + @apply bg-gray-100 rounded; +} + +::-webkit-scrollbar-thumb { + @apply bg-gray-300 rounded hover:bg-gray-400; +} diff --git a/frontend/src/utils/helpers.js b/frontend/src/utils/helpers.js new file mode 100644 index 0000000..19b5e8b --- /dev/null +++ b/frontend/src/utils/helpers.js @@ -0,0 +1,145 @@ +/** + * 格式化日期时间 + * @param {string|Date} date - 日期 + * @param {boolean} includeTime - 是否包含时间 + * @returns {string} + */ +export function formatDateTime(date, includeTime = true) { + if (!date) return '-' + + const d = new Date(date) + if (isNaN(d.getTime())) return '-' + + const year = d.getFullYear() + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + + if (!includeTime) { + return `${year}-${month}-${day}` + } + + const hours = String(d.getHours()).padStart(2, '0') + const minutes = String(d.getMinutes()).padStart(2, '0') + const seconds = String(d.getSeconds()).padStart(2, '0') + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` +} + +/** + * 格式化相对时间(多久之前) + * @param {string|Date} date - 日期 + * @returns {string} + */ +export function formatRelativeTime(date) { + if (!date) return '-' + + const d = new Date(date) + if (isNaN(d.getTime())) return '-' + + const now = new Date() + const diff = now - d // 毫秒差 + + const seconds = Math.floor(diff / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (seconds < 60) return '刚刚' + if (minutes < 60) return `${minutes} 分钟前` + if (hours < 24) return `${hours} 小时前` + if (days < 7) return `${days} 天前` + + return formatDateTime(date, false) +} + +/** + * 格式化文件大小 + * @param {number} bytes - 字节数 + * @returns {string} + */ +export function formatFileSize(bytes) { + if (!bytes || bytes === 0) return '0 B' + + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}` +} + +/** + * 防抖函数 + * @param {Function} fn - 要防抖的函数 + * @param {number} delay - 延迟时间(毫秒) + * @returns {Function} + */ +export function debounce(fn, delay = 300) { + let timer = null + return function (...args) { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + fn.apply(this, args) + }, delay) + } +} + +/** + * 节流函数 + * @param {Function} fn - 要节流的函数 + * @param {number} delay - 延迟时间(毫秒) + * @returns {Function} + */ +export function throttle(fn, delay = 300) { + let timer = null + let lastTime = 0 + + return function (...args) { + const now = Date.now() + + if (now - lastTime < delay) { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + lastTime = now + fn.apply(this, args) + }, delay) + } else { + lastTime = now + fn.apply(this, args) + } + } +} + +/** + * 复制文本到剪贴板 + * @param {string} text - 要复制的文本 + * @returns {Promise} + */ +export async function copyToClipboard(text) { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text) + return true + } else { + // 降级方案 + const textArea = document.createElement('textarea') + textArea.value = text + textArea.style.position = 'fixed' + textArea.style.left = '-999999px' + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + try { + document.execCommand('copy') + textArea.remove() + return true + } catch (error) { + console.error('复制失败', error) + textArea.remove() + return false + } + } + } catch (error) { + console.error('复制失败', error) + return false + } +} diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue new file mode 100644 index 0000000..b5c5b8d --- /dev/null +++ b/frontend/src/views/DashboardView.vue @@ -0,0 +1,374 @@ + + + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..7944d96 --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,379 @@ + + + + + diff --git a/frontend/src/views/NotFoundView.vue b/frontend/src/views/NotFoundView.vue new file mode 100644 index 0000000..0c5973d --- /dev/null +++ b/frontend/src/views/NotFoundView.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/frontend/src/views/PendingApprovalView.vue b/frontend/src/views/PendingApprovalView.vue new file mode 100644 index 0000000..eabac3a --- /dev/null +++ b/frontend/src/views/PendingApprovalView.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/frontend/src/views/RecordsView.vue b/frontend/src/views/RecordsView.vue new file mode 100644 index 0000000..a245d30 --- /dev/null +++ b/frontend/src/views/RecordsView.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue new file mode 100644 index 0000000..28bf4aa --- /dev/null +++ b/frontend/src/views/SettingsView.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/frontend/src/views/TaskRecordsView.vue b/frontend/src/views/TaskRecordsView.vue new file mode 100644 index 0000000..9fc285e --- /dev/null +++ b/frontend/src/views/TaskRecordsView.vue @@ -0,0 +1,358 @@ + + + + + diff --git a/frontend/src/views/TasksView.vue b/frontend/src/views/TasksView.vue new file mode 100644 index 0000000..424ab8d --- /dev/null +++ b/frontend/src/views/TasksView.vue @@ -0,0 +1,779 @@ + + + + + diff --git a/frontend/src/views/TasksView.vue.backup b/frontend/src/views/TasksView.vue.backup new file mode 100644 index 0000000..dff46be --- /dev/null +++ b/frontend/src/views/TasksView.vue.backup @@ -0,0 +1,733 @@ + + + + + diff --git a/frontend/src/views/admin/LogsView.vue b/frontend/src/views/admin/LogsView.vue new file mode 100644 index 0000000..d09ab50 --- /dev/null +++ b/frontend/src/views/admin/LogsView.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/frontend/src/views/admin/RecordsView.vue b/frontend/src/views/admin/RecordsView.vue new file mode 100644 index 0000000..ff7362e --- /dev/null +++ b/frontend/src/views/admin/RecordsView.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/frontend/src/views/admin/StatsView.vue b/frontend/src/views/admin/StatsView.vue new file mode 100644 index 0000000..b15d24b --- /dev/null +++ b/frontend/src/views/admin/StatsView.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/frontend/src/views/admin/TemplatesView.vue b/frontend/src/views/admin/TemplatesView.vue new file mode 100644 index 0000000..f231432 --- /dev/null +++ b/frontend/src/views/admin/TemplatesView.vue @@ -0,0 +1,592 @@ + + + + + diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue new file mode 100644 index 0000000..3453bc9 --- /dev/null +++ b/frontend/src/views/admin/UsersView.vue @@ -0,0 +1,543 @@ + + + + + diff --git a/frontend/src/views/admin/UsersView.vue.backup b/frontend/src/views/admin/UsersView.vue.backup new file mode 100644 index 0000000..a1bb37b --- /dev/null +++ b/frontend/src/views/admin/UsersView.vue.backup @@ -0,0 +1,370 @@ +