⚒️ 重大重构 LoveACE V2
引入了 mongodb 对数据库进行了一定程度的数据加密 性能改善 代码简化 统一错误模型和响应 使用 apifox 作为文档
This commit is contained in:
@@ -1,10 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{js,json,yml}]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -1,4 +1,4 @@
|
||||
# LoveAC Project .gitattributes
|
||||
# LoveACE Project .gitattributes
|
||||
# 语言检测和统计配置
|
||||
|
||||
# ==============================================
|
||||
|
||||
75
.github/workflows/deploy-docs.yml
vendored
75
.github/workflows/deploy-docs.yml
vendored
@@ -1,75 +0,0 @@
|
||||
name: 部署文档
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'openapi.json'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- '.github/workflows/deploy-docs.yml'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# 构建作业
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 设置pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 复制OpenAPI文件到public目录
|
||||
run: |
|
||||
mkdir -p docs/public
|
||||
cp openapi.json docs/public/
|
||||
|
||||
- name: 构建文档
|
||||
run: pnpm docs:build
|
||||
env:
|
||||
NODE_ENV: production
|
||||
|
||||
- name: 设置Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: 上传构建产物
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/.vitepress/dist
|
||||
|
||||
# 部署作业
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- name: 部署到GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,13 +1,13 @@
|
||||
# =====================================================
|
||||
# LoveAC Project .gitignore
|
||||
# LoveACE Project .gitignore
|
||||
# =====================================================
|
||||
|
||||
# ===== 敏感信息和配置文件 =====
|
||||
# 配置文件(包含数据库密码等敏感信息)
|
||||
config.json
|
||||
config_local.json
|
||||
config_prod.json
|
||||
config_dev.json
|
||||
config.json
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -60,11 +60,15 @@ user_data/
|
||||
*.mov
|
||||
|
||||
# ===== Python 相关 =====
|
||||
# RUFF
|
||||
.ruff-cache/
|
||||
|
||||
# 字节码文件
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
*.pyc
|
||||
|
||||
# 分发/打包
|
||||
.Python
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"default": true,
|
||||
"MD013": {
|
||||
"line_length": 120,
|
||||
"code_blocks": false,
|
||||
"tables": false
|
||||
},
|
||||
"MD024": {
|
||||
"siblings_only": true
|
||||
},
|
||||
"MD033": {
|
||||
"allowed_elements": [
|
||||
"div",
|
||||
"script",
|
||||
"template",
|
||||
"style",
|
||||
"br",
|
||||
"img",
|
||||
"span",
|
||||
"a",
|
||||
"strong",
|
||||
"em",
|
||||
"code",
|
||||
"pre"
|
||||
]
|
||||
},
|
||||
"MD041": false,
|
||||
"MD025": {
|
||||
"front_matter_title": "^\\s*title\\s*[:=]"
|
||||
}
|
||||
}
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
9
LICENSE
9
LICENSE
@@ -9,6 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
COMMERCIAL USE RESTRICTION:
|
||||
This software is NOT intended for commercial use. Any unauthorized commercial
|
||||
use of this software is strictly prohibited. All legal liabilities, financial
|
||||
losses, and other risks arising from unauthorized commercial use shall be
|
||||
borne solely by the commercial user and are not the responsibility of the
|
||||
software authors or contributors.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
@@ -18,4 +25,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
SOFTWARE.
|
||||
291
README.md
291
README.md
@@ -1,159 +1,115 @@
|
||||
# LoveACE - 财大教务自动化工具
|
||||
# LoveACE - 财大自动化工具
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="logo.jpg" alt="LoveAC Logo" width="120" height="120" />
|
||||
|
||||
**简化学生教务操作,提高使用效率**
|
||||
|
||||
[](LICENSE)
|
||||
[](https://python.org)
|
||||
[](https://fastapi.tiangolo.com)
|
||||
[]
|
||||
|
||||
<img src="https://cdn.apifox.com/app/project-icon/custom/20251011/e20b3227-13dd-4057-b1d3-dc821294d914.jpeg" alt="LoveACE Logo" width="120" height="120" />
|
||||
|
||||
**Make It Easy**
|
||||
</div>
|
||||
|
||||
## 🚀 项目简介
|
||||
|
||||
LoveACE 是一个面向安徽财经大学的教务系统自动化工具,专为安徽财经大学教务OA系统设计。通过RESTful API接口,提供自动评教(开发中)、课表查询、成绩查询等功能,大幅简化学生的教务操作流程。
|
||||
LoveACE 是一个面向安徽财经大学的教务系统自动化工具,专为安徽财经大学各类系统设计。通过 RESTful API 接口,提供课表查询、成绩查询、积分查询、宿舍管理等功能,大幅简化学生的日常操作流程。
|
||||
|
||||
### ✨ 主要特性
|
||||
|
||||
- **🔐 安全认证**: 基于邀请码的用户注册系统,确保使用安全
|
||||
- **📚 教务集成**: 深度集成教务系统,支持学业信息、培养方案查询
|
||||
- **⭐ 智能评教**: 全自动评教系统,支持任务管理和进度监控
|
||||
- **🔐 安全认证**: 基于 Token 的用户认证系统,RSA 加密保护敏感信息
|
||||
- **📚 教务集成**: 深度集成教务系统,支持成绩、课表、考试、培养方案、学业信息查询
|
||||
- **💯 积分查询**: 爱安财系统集成,实时查询积分和明细
|
||||
- **🚀 高性能**: 基于FastAPI构建,支持异步处理和高并发
|
||||
- **📖 完整文档**: 提供详细的API文档和部署指南
|
||||
- **🏠 宿舍管理**: ISIM系统集成,支持电费查询和房间信息查询
|
||||
- **🚀 高性能**: 基于 FastAPI 构建,支持异步处理和高并发
|
||||
- **📊 中间件支持**: 请求处理时间监控、CORS 配置
|
||||
- **🔒 数据安全**: RSA 加密存储敏感信息,保护用户隐私
|
||||
|
||||
### 🛠️ 技术栈
|
||||
|
||||
- **后端框架**: [FastAPI](https://fastapi.tiangolo.com/) - 现代、快速的Python Web框架
|
||||
- **数据库**: [SQLAlchemy](https://sqlalchemy.org/) (异步) - 强大的ORM工具
|
||||
- **HTTP客户端**: 基于[aiohttp](https://aiohttp.readthedocs.io/)的自定义异步客户端
|
||||
- **日志系统**: [richuru](https://github.com/GreyElaina/richuru) - rich + loguru的完美结合
|
||||
- **文档系统**: [VitePress](https://vitepress.dev/) - 现代化的文档生成工具
|
||||
- **后端框架**: [FastAPI](https://fastapi.tiangolo.com/) - 现代、快速的 Python Web 框架
|
||||
- **数据库**: [SQLAlchemy](https://sqlalchemy.org/) (异步) + [aiomysql](https://aiomysql.readthedocs.io/) - 强大的异步 ORM
|
||||
- **HTTP客户端**: [httpx](https://www.python-httpx.org/) - 现代化的异步 HTTP 客户端
|
||||
- **日志系统**: [richuru](https://github.com/GreyElaina/richuru) - rich + loguru 的完美结合
|
||||
- **包管理**: [uv](https://github.com/astral-sh/uv) - 极速 Python 包管理器
|
||||
- **加密工具**: [cryptography](https://cryptography.io/) - RSA 加密支持
|
||||
- **数据解析**: [BeautifulSoup4](https://www.crummy.com/software/BeautifulSoup/) + [lxml](https://lxml.de/) - HTML 解析
|
||||
|
||||
## 📦 快速开始
|
||||
## 📚 API 功能
|
||||
|
||||
### 前置条件
|
||||
### 认证模块 (`/auth`)
|
||||
- **用户注册**: 创建新用户账号
|
||||
- **用户登录**: 获取访问令牌
|
||||
- **身份验证**: 验证当前用户身份和令牌有效性
|
||||
|
||||
- **Python 3.12+**
|
||||
- **PDM**
|
||||
- **MySQL** 数据库
|
||||
### 教务系统 (`/jwc`)
|
||||
- **成绩查询**: 查询学期成绩和历史成绩
|
||||
- **课表查询**: 获取当前学期课程表
|
||||
- **考试安排**: 查看考试时间和地点
|
||||
- **培养方案**: 查询专业培养方案
|
||||
- **学业信息**: 获取学生基本学业信息
|
||||
- **学期信息**: 查询学期列表
|
||||
|
||||
### 安装部署
|
||||
### 爱安财系统 (`/aac`)
|
||||
- **学分查询**: 查询总的爱安财学分
|
||||
- **学分明细**: 获取爱安财学分明细
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone https://github.com/LoveACE-Team/LoveACE.git
|
||||
cd LoveACE
|
||||
|
||||
# 2. 安装依赖
|
||||
pdm install
|
||||
|
||||
# 3. 配置环境
|
||||
python main.py
|
||||
# 首次启动会生成默认配置,随后自行编辑 config.json 填写数据库配置和其他设置
|
||||
|
||||
# 4. 启动服务
|
||||
python main.py
|
||||
```
|
||||
|
||||
服务启动后访问(以实际为准):
|
||||
- **API服务**: http://localhost:8000
|
||||
- **API文档**: http://localhost:8000/docs
|
||||
### 宿舍管理 (`/isim`)
|
||||
- **电费查询**: 查询宿舍剩余电费
|
||||
- **房间信息**: 获取宿舍房间详细信息
|
||||
|
||||
## 📚 文档
|
||||
|
||||
### 在线文档
|
||||
访问我们的在线文档获取完整指南:**https://LoveACE-team.github.io/LoveACE**
|
||||
### API 文档
|
||||
启动服务后,在 debug 模式下访问:
|
||||
- **Swagger UI**: http://localhost:4500/docs
|
||||
- **ReDoc**: http://localhost:4500/redoc
|
||||
- **OpenAPI Schema**: http://localhost:4500/openapi.json
|
||||
|
||||
### 文档内容
|
||||
- **📖 快速开始**: 安装和基本使用指南
|
||||
- **⚙️ 配置指南**: 详细的配置选项说明
|
||||
- **🚀 部署指南**: 生产环境部署教程
|
||||
- **📡 API文档**: 交互式API文档 (基于OpenAPI)
|
||||
- **🤝 贡献指南**: 如何参与项目开发
|
||||
- **⚖️ 免责声明**: 使用须知和免责条款
|
||||
|
||||
### 本地构建文档
|
||||
|
||||
```bash
|
||||
# 安装文档依赖
|
||||
yarn install
|
||||
|
||||
# 启动开发服务器
|
||||
yarn docs:dev
|
||||
|
||||
# 构建静态文档
|
||||
yarn docs:build
|
||||
```
|
||||
> **注意**: 生产环境下,文档接口默认关闭,需在配置文件中设置 `app.debug = true` 启用。
|
||||
|
||||
## 🏗️ 项目结构
|
||||
|
||||
```
|
||||
LoveAC/
|
||||
├── 📁 database/ # 数据库相关代码
|
||||
│ ├── creator.py # 数据库会话管理
|
||||
│ ├── base.py # 基础模型定义
|
||||
│ └── user.py # 用户数据模型
|
||||
├── 📁 provider/ # 服务提供者
|
||||
│ ├── aufe/ # 安徽财经大学服务
|
||||
│ │ ├── client.py # 基础HTTP客户端
|
||||
│ │ ├── jwc/ # 教务系统集成
|
||||
│ │ └── aac/ # 爱安财系统集成
|
||||
│ └── loveac/ # 内部服务
|
||||
├── 📁 router/ # API路由定义
|
||||
│ ├── common_model.py # 通用响应模型
|
||||
│ ├── invite/ # 邀请码相关路由
|
||||
│ ├── login/ # 登录认证路由
|
||||
│ ├── jwc/ # 教务系统路由
|
||||
│ └── aac/ # 爱安财系统路由
|
||||
├── 📁 utils/ # 工具函数
|
||||
├── 📁 config/ # 配置管理
|
||||
├── 📁 docs/ # 项目文档
|
||||
├── 📄 main.py # 应用入口文件
|
||||
├── 📄 config.json # 配置文件
|
||||
├── 📄 openapi.json # OpenAPI规范文件(FastAPI生成)
|
||||
└── 📄 pyproject.toml # 项目依赖配置
|
||||
LoveACE-V2/
|
||||
├── 📁 loveace/ # 主应用目录
|
||||
│ ├── 📁 config/ # 配置管理
|
||||
│ │ ├── logger.py # 日志配置
|
||||
│ │ ├── manager.py # 配置管理器
|
||||
│ │ └── settings.py # 配置模型
|
||||
│ ├── 📁 database/ # 数据库相关代码
|
||||
│ │ ├── creator.py # 数据库会话管理
|
||||
│ │ ├── base/ # 基础模型定义
|
||||
│ │ ├── auth/ # 认证相关模型 (用户、令牌、登录、注册)
|
||||
│ │ ├── aac/ # 爱安财积分票据模型
|
||||
│ │ └── isim/ # 宿舍管理模型
|
||||
│ ├── 📁 router/ # API路由定义
|
||||
│ │ ├── dependencies/ # 路由依赖项 (认证、日志等)
|
||||
│ │ ├── endpoint/ # API端点
|
||||
│ │ │ ├── auth/ # 认证路由 (登录、注册、authme)
|
||||
│ │ │ ├── jwc/ # 教务系统路由 (成绩、课表、考试、培养方案等)
|
||||
│ │ │ ├── aac/ # 爱安财系统路由 (积分查询)
|
||||
│ │ │ └── isim/ # 宿舍管理路由 (电费、房间信息)
|
||||
│ │ └── schemas/ # 通用响应模型和错误处理
|
||||
│ ├── 📁 service/ # 服务层
|
||||
│ │ ├── model/ # 服务模型
|
||||
│ │ └── remote/ # 远程服务
|
||||
│ │ └── aufe/ # 安徽财经大学服务集成
|
||||
│ ├── 📁 middleware/ # 中间件
|
||||
│ │ └── process_time.py # 请求处理时间中间件
|
||||
│ └── 📁 utils/ # 工具函数
|
||||
│ ├── richuru_hook.py # Rich + Loguru 集成
|
||||
│ └── rsa.py # RSA 加密工具
|
||||
├── 📁 data/ # 数据文件
|
||||
│ ├── isim_rooms.json # 宿舍房间数据
|
||||
│ └── keys/ # RSA密钥对
|
||||
├── 📁 logs/ # 日志文件目录
|
||||
├── 📄 main.py # 应用入口文件
|
||||
├── 📄 config.json # 配置文件
|
||||
├── 📄 pyproject.toml # 项目依赖配置 (uv)
|
||||
├── 📄 uv.lock # 依赖锁定文件
|
||||
└── 📄 README.md # 项目说明文档
|
||||
```
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
### 数据库配置
|
||||
```json
|
||||
{
|
||||
"database": {
|
||||
"url": "mysql+aiomysql://username:password@host:port/database",
|
||||
"pool_size": 10,
|
||||
"max_overflow": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 应用配置
|
||||
```json
|
||||
{
|
||||
"app": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8000,
|
||||
"debug": false,
|
||||
"cors_allow_origins": ["*"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
完整配置选项请参考 [配置指南](https://LoveACE-team.github.io/LoveACE/config)。
|
||||
|
||||
## 🚀 部署
|
||||
|
||||
详细部署指南请参考 [部署文档](https://LoveACE-team.github.io/LoveACE/deploy)。
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
我们欢迎所有形式的贡献!在参与之前,请阅读我们的 [贡献指南](https://LoveACE-team.github.io/LoveACE/contributing)。
|
||||
我们欢迎所有形式的贡献!
|
||||
|
||||
### 贡献方式
|
||||
|
||||
@@ -162,26 +118,101 @@ LoveAC/
|
||||
- 📝 **代码贡献**: 提交Pull Request
|
||||
- 📖 **文档改进**: 帮助完善文档
|
||||
|
||||
### 开发指南
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/LoveACE-Team/LoveACE.git
|
||||
cd LoveACE
|
||||
|
||||
# 安装开发依赖
|
||||
uv sync --group dev
|
||||
|
||||
# 代码格式化
|
||||
black .
|
||||
isort .
|
||||
|
||||
# 代码检查
|
||||
ruff check .
|
||||
```
|
||||
|
||||
## ⚖️ 免责声明
|
||||
|
||||
**重要提醒**: 本软件仅供学习和个人使用,请在使用前仔细阅读 [免责声明](https://LoveACE-team.github.io/LoveACE/disclaimer)。
|
||||
**重要提醒**: 本软件仅供学习、研究和个人非商业用途使用。
|
||||
|
||||
- ✅ 本软件为教育目的开发的开源项目
|
||||
- ⚠️ 使用时请遵守学校相关规定和法律法规
|
||||
- 🛡️ 请妥善保管个人账户信息
|
||||
- ❌ 不得用于任何商业用途
|
||||
### 使用条款
|
||||
|
||||
- ✅ **开源性质**: 本软件为教育目的开发的开源项目,遵循 MIT 许可证
|
||||
- 📚 **用途限制**: 仅限于学习交流、技术研究等非商业用途
|
||||
- ⚠️ **合规使用**: 使用时请严格遵守学校相关规定、服务条款及您所在地的法律法规
|
||||
- 🛡️ **账户安全**: 请妥善保管个人账户信息,不要与他人共享,避免账号泄露
|
||||
- 🔒 **隐私保护**: 本软件不会主动收集、存储或泄露用户的个人信息
|
||||
|
||||
### 商业使用禁止
|
||||
|
||||
- ❌ **严禁商用**: 本软件不得用于任何形式的商业用途,包括但不限于:
|
||||
- 收费服务或产品
|
||||
- 商业广告和推广
|
||||
- 未经授权的数据采集和销售
|
||||
- ⚠️ **风险自负**: 任何未经授权的商业使用所产生的法律责任、经济损失、侵权纠纷及其他风险,均由商业使用者自行承担,与本软件作者及所有贡献者无关
|
||||
|
||||
### 免责条款
|
||||
|
||||
- 🚫 **后果免责**: 开发者及贡献者不对使用本软件造成的任何直接或间接后果负责,包括但不限于:
|
||||
- 账号封禁或处罚
|
||||
- 数据丢失或泄露
|
||||
- 服务中断或错误
|
||||
- 学业或经济损失
|
||||
- 🔧 **无担保**: 本软件按"现状"提供,不提供任何明示或暗示的担保,包括但不限于适销性、特定用途适用性的担保
|
||||
- 📋 **自行判断**: 用户应自行判断使用本软件的风险,并承担使用本软件的全部责任
|
||||
|
||||
### 接受条款
|
||||
|
||||
- 📜 **视为同意**: 下载、安装、使用本软件或对本软件进行任何形式的操作,即表示您已充分阅读、理解并同意接受本免责声明的所有条款
|
||||
- ⛔ **不同意则停止**: 如果您不同意本免责声明的任何条款,请立即停止使用本软件并删除所有相关文件
|
||||
|
||||
## 📞 支持与联系
|
||||
|
||||
- 📧 **邮箱**: [sibuxiang@proton.me](mailto:sibuxiang@proton.me)
|
||||
- 🐛 **Bug报告**: [GitHub Issues](https://github.com/LoveACE-Team/LoveACE/issues)
|
||||
- 💬 **讨论交流**: [GitHub Discussions](https://github.com/LoveACE-Team/LoveACE/discussions)
|
||||
- 📖 **在线文档**: [项目文档](https://LoveACE-team.github.io/LoveACE)
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 [MIT许可证](LICENSE) 开源。
|
||||
|
||||
**重要商业使用限制**: 本软件不得用于商业用途。任何未经授权的商业使用所产生的一切法律责任、经济损失及其他风险,均由商业使用者自行承担,与本软件作者及贡献者无关。
|
||||
|
||||
```bash
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 LoveACE Team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
COMMERCIAL USE RESTRICTION:
|
||||
This software is NOT intended for commercial use. Any unauthorized commercial
|
||||
use of this software is strictly prohibited. All legal liabilities, financial
|
||||
losses, and other risks arising from unauthorized commercial use shall be
|
||||
borne solely by the commercial user and are not the responsibility of the
|
||||
software authors or contributors.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
from .manager import config_manager, Settings
|
||||
from .models import DatabaseConfig, AUFEConfig, S3Config, LogConfig, AppConfig
|
||||
|
||||
__all__ = [
|
||||
"config_manager",
|
||||
"Settings",
|
||||
"DatabaseConfig",
|
||||
"AUFEConfig",
|
||||
"S3Config",
|
||||
"LogConfig",
|
||||
"AppConfig"
|
||||
]
|
||||
@@ -1,67 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from richuru import install
|
||||
from loguru import logger
|
||||
|
||||
from .manager import config_manager
|
||||
|
||||
|
||||
def setup_logger():
|
||||
"""根据配置文件设置loguru日志"""
|
||||
install()
|
||||
settings = config_manager.get_settings()
|
||||
log_config = settings.log
|
||||
|
||||
# 移除默认的logger配置
|
||||
logger.remove()
|
||||
|
||||
# 确保日志目录存在
|
||||
log_dir = Path(log_config.file_path).parent
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 设置控制台输出
|
||||
if log_config.console_output:
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
format=log_config.format,
|
||||
level=log_config.level.value,
|
||||
backtrace=log_config.backtrace,
|
||||
diagnose=log_config.diagnose,
|
||||
)
|
||||
|
||||
# 设置主日志文件
|
||||
logger.add(
|
||||
log_config.file_path,
|
||||
format=log_config.format,
|
||||
level=log_config.level.value,
|
||||
rotation=log_config.rotation,
|
||||
retention=log_config.retention,
|
||||
compression=log_config.compression,
|
||||
backtrace=log_config.backtrace,
|
||||
diagnose=log_config.diagnose,
|
||||
)
|
||||
|
||||
# 设置额外的日志记录器
|
||||
for extra_logger in log_config.additional_loggers:
|
||||
# 确保额外日志目录存在
|
||||
extra_log_dir = Path(extra_logger["file_path"]).parent
|
||||
extra_log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.add(
|
||||
extra_logger["file_path"],
|
||||
format=log_config.format,
|
||||
level=extra_logger.get("level", log_config.level.value),
|
||||
rotation=extra_logger.get("rotation", log_config.rotation),
|
||||
retention=extra_logger.get("retention", log_config.retention),
|
||||
compression=extra_logger.get("compression", log_config.compression),
|
||||
backtrace=log_config.backtrace,
|
||||
diagnose=log_config.diagnose,
|
||||
filter=extra_logger.get("filter"),
|
||||
)
|
||||
|
||||
logger.info("日志系统初始化完成")
|
||||
|
||||
|
||||
def get_logger():
|
||||
"""获取配置好的logger实例"""
|
||||
return logger
|
||||
@@ -1,4 +0,0 @@
|
||||
from .creator import db_manager, get_db_session
|
||||
from .base import Base
|
||||
|
||||
__all__ = ["db_manager", "get_db_session", "Base"]
|
||||
@@ -1,79 +0,0 @@
|
||||
from typing import AsyncGenerator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from .base import Base
|
||||
from config import config_manager
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class DatabaseManager:
|
||||
def __init__(self):
|
||||
self.engine = None
|
||||
self.async_session_maker = None
|
||||
self._config = None
|
||||
|
||||
def _get_db_config(self):
|
||||
"""获取数据库配置"""
|
||||
if self._config is None:
|
||||
self._config = config_manager.get_settings().database
|
||||
return self._config
|
||||
|
||||
async def init_db(self):
|
||||
"""初始化数据库连接"""
|
||||
db_config = self._get_db_config()
|
||||
|
||||
logger.info("正在初始化数据库连接...")
|
||||
try:
|
||||
self.engine = create_async_engine(
|
||||
db_config.url,
|
||||
echo=db_config.echo,
|
||||
pool_size=db_config.pool_size,
|
||||
max_overflow=db_config.max_overflow,
|
||||
pool_timeout=db_config.pool_timeout,
|
||||
pool_recycle=db_config.pool_recycle,
|
||||
future=True
|
||||
)
|
||||
|
||||
|
||||
self.async_session_maker = async_sessionmaker(
|
||||
self.engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
# 创建所有表
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
except Exception as e:
|
||||
logger.error(f"数据库连接初始化失败: {e}")
|
||||
logger.error(f"数据库连接URL: {db_config.url}")
|
||||
logger.error(f"数据库连接配置: {db_config}")
|
||||
logger.error("请启动config_tui.py来配置数据库连接")
|
||||
raise
|
||||
logger.info("数据库连接初始化完成")
|
||||
|
||||
async def close_db(self):
|
||||
"""关闭数据库连接"""
|
||||
if self.engine:
|
||||
logger.info("正在关闭数据库连接...")
|
||||
await self.engine.dispose()
|
||||
logger.info("数据库连接已关闭")
|
||||
|
||||
async def get_session(self) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""获取数据库会话"""
|
||||
if not self.async_session_maker:
|
||||
raise RuntimeError("Database not initialized. Call init_db() first.")
|
||||
|
||||
async with self.async_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
# 全局数据库管理器实例
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
|
||||
# FastAPI 依赖函数
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""获取数据库会话的依赖函数,用于FastAPI路由"""
|
||||
async for session in db_manager.get_session():
|
||||
yield session
|
||||
@@ -1,26 +0,0 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import func, String
|
||||
from sqlalchemy.orm import Mapped
|
||||
from sqlalchemy.orm import mapped_column
|
||||
from database.base import Base
|
||||
|
||||
|
||||
class ISIMRoomBinding(Base):
|
||||
"""ISIM系统房间绑定表"""
|
||||
__tablename__ = "isim_room_binding_table"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
userid: Mapped[str] = mapped_column(String(100), nullable=False, index=True, comment="用户ID")
|
||||
building_code: Mapped[str] = mapped_column(String(10), nullable=False, comment="楼栋代码")
|
||||
building_name: Mapped[str] = mapped_column(String(100), nullable=False, comment="楼栋名称")
|
||||
floor_code: Mapped[str] = mapped_column(String(10), nullable=False, comment="楼层代码")
|
||||
floor_name: Mapped[str] = mapped_column(String(50), nullable=False, comment="楼层名称")
|
||||
room_code: Mapped[str] = mapped_column(String(20), nullable=False, comment="房间代码")
|
||||
room_name: Mapped[str] = mapped_column(String(50), nullable=False, comment="房间名称")
|
||||
room_id: Mapped[str] = mapped_column(String(20), nullable=False, comment="房间ID(楼栋+楼层+房间)")
|
||||
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
|
||||
update_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
# 注释:电费记录和充值记录都实时获取,不存储在数据库中
|
||||
@@ -1,52 +0,0 @@
|
||||
import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import func, String
|
||||
from sqlalchemy.orm import Mapped
|
||||
from sqlalchemy.orm import mapped_column
|
||||
from database.base import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "user_table"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
userid: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
easyconnect_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
|
||||
|
||||
|
||||
class UserProfile(Base):
|
||||
__tablename__ = "user_profile_table"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
userid: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
avatar_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment="用户头像文件名")
|
||||
background_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment="用户背景文件名")
|
||||
nickname: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, comment="用户昵称")
|
||||
settings_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment="用户设置文件名")
|
||||
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
|
||||
update_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class Invite(Base):
|
||||
__tablename__ = "invite_table"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
invite_code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
||||
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
|
||||
|
||||
|
||||
class AuthME(Base):
|
||||
__tablename__ = "authme_table"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
userid: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
authme_token: Mapped[str] = mapped_column(String(500), unique=True, nullable=False)
|
||||
device_id: Mapped[str] = mapped_column(String(100), nullable=False, comment="设备/会话标识符")
|
||||
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
|
||||
|
||||
|
||||
class AACTicket(Base):
|
||||
__tablename__ = "aac_ticket_table"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
userid: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
aac_token: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
|
||||
@@ -1,200 +0,0 @@
|
||||
<template>
|
||||
<div ref="swaggerContainer" id="swagger-ui"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const swaggerContainer = ref<HTMLElement>()
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
// 加载CSS
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = '/swagger-ui.css'
|
||||
document.head.appendChild(link)
|
||||
|
||||
// 加载SwaggerUI Bundle
|
||||
const bundleScript = document.createElement('script')
|
||||
bundleScript.src = '/swagger-ui-bundle.js'
|
||||
bundleScript.crossOrigin = 'anonymous'
|
||||
|
||||
// 加载Standalone Preset
|
||||
const presetScript = document.createElement('script')
|
||||
presetScript.src = '/swagger-ui-standalone-preset.js'
|
||||
presetScript.crossOrigin = 'anonymous'
|
||||
|
||||
// 等待两个脚本都加载完成
|
||||
let bundleLoaded = false
|
||||
let presetLoaded = false
|
||||
|
||||
const initSwagger = () => {
|
||||
if (bundleLoaded && presetLoaded) {
|
||||
// @ts-ignore
|
||||
window.ui = window.SwaggerUIBundle({
|
||||
url: '/openapi.json',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
// @ts-ignore
|
||||
SwaggerUIBundle.presets.apis,
|
||||
// @ts-ignore
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
// @ts-ignore
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "StandaloneLayout",
|
||||
tryItOutEnabled: true,
|
||||
displayRequestDuration: true,
|
||||
showExtensions: true,
|
||||
showCommonExtensions: true,
|
||||
requestInterceptor: (request: any) => {
|
||||
// 可以在这里添加认证头或其他请求拦截
|
||||
console.log('请求拦截:', request)
|
||||
return request
|
||||
},
|
||||
responseInterceptor: (response: any) => {
|
||||
// 可以在这里处理响应
|
||||
console.log('响应拦截:', response)
|
||||
return response
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
bundleScript.onload = () => {
|
||||
bundleLoaded = true
|
||||
initSwagger()
|
||||
}
|
||||
|
||||
presetScript.onload = () => {
|
||||
presetLoaded = true
|
||||
initSwagger()
|
||||
}
|
||||
|
||||
bundleScript.onerror = () => {
|
||||
console.error('加载SwaggerUI Bundle失败')
|
||||
}
|
||||
|
||||
presetScript.onerror = () => {
|
||||
console.error('加载SwaggerUI Preset失败')
|
||||
}
|
||||
|
||||
document.head.appendChild(bundleScript)
|
||||
document.head.appendChild(presetScript)
|
||||
} catch (error) {
|
||||
console.error('加载SwaggerUI失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理动态添加的脚本和样式
|
||||
const bundleScripts = document.querySelectorAll('script[src*="swagger-ui-bundle"]')
|
||||
const presetScripts = document.querySelectorAll('script[src*="swagger-ui-standalone-preset"]')
|
||||
const links = document.querySelectorAll('link[href*="swagger-ui.css"]')
|
||||
|
||||
bundleScripts.forEach(script => script.remove())
|
||||
presetScripts.forEach(script => script.remove())
|
||||
links.forEach(link => link.remove())
|
||||
|
||||
// 清理全局变量
|
||||
if (typeof window !== 'undefined' && (window as any).ui) {
|
||||
delete (window as any).ui
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* SwaggerUI 容器样式 */
|
||||
#swagger-ui {
|
||||
font-family: var(--vp-font-family-base);
|
||||
width: 100%;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
/* 调整SwaggerUI的主题以匹配VitePress */
|
||||
#swagger-ui .swagger-ui .topbar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#swagger-ui .swagger-ui .info {
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
#swagger-ui .swagger-ui .scheme-container {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
#swagger-ui .swagger-ui .opblock {
|
||||
border: 1px solid var(--vp-c-border);
|
||||
border-radius: 8px;
|
||||
margin: 10px 0;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
#swagger-ui .swagger-ui .opblock.opblock-post {
|
||||
border-color: var(--vp-c-green-2);
|
||||
background: var(--vp-c-green-soft);
|
||||
}
|
||||
|
||||
#swagger-ui .swagger-ui .opblock.opblock-get {
|
||||
border-color: var(--vp-c-blue-2);
|
||||
background: var(--vp-c-blue-soft);
|
||||
}
|
||||
|
||||
#swagger-ui .swagger-ui .opblock.opblock-put {
|
||||
border-color: var(--vp-c-yellow-2);
|
||||
background: var(--vp-c-yellow-soft);
|
||||
}
|
||||
|
||||
#swagger-ui .swagger-ui .opblock.opblock-delete {
|
||||
border-color: var(--vp-c-red-2);
|
||||
background: var(--vp-c-red-soft);
|
||||
}
|
||||
|
||||
#swagger-ui .swagger-ui .opblock-summary {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
#swagger-ui .swagger-ui .opblock-description-wrapper,
|
||||
#swagger-ui .swagger-ui .opblock-external-docs-wrapper,
|
||||
#swagger-ui .swagger-ui .opblock-title_normal {
|
||||
padding: 15px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
#swagger-ui .swagger-ui {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#swagger-ui .swagger-ui .opblock-summary {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色模式适配 */
|
||||
.dark #swagger-ui .swagger-ui {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.dark #swagger-ui .swagger-ui .opblock {
|
||||
background: var(--vp-c-bg-elv);
|
||||
}
|
||||
|
||||
.dark #swagger-ui .swagger-ui .scheme-container {
|
||||
background: var(--vp-c-bg-elv);
|
||||
}
|
||||
</style>
|
||||
@@ -1,75 +0,0 @@
|
||||
import { defineConfig } from 'vitepress'
|
||||
|
||||
export default defineConfig({
|
||||
title: 'LoveACE',
|
||||
description: '教务系统自动化工具',
|
||||
lang: 'zh-CN',
|
||||
|
||||
themeConfig: {
|
||||
logo: '/images/logo.jpg',
|
||||
|
||||
nav: [
|
||||
{ text: '首页', link: '/' },
|
||||
{ text: 'API文档', link: '/api/' },
|
||||
{ text: '配置', link: '/config' },
|
||||
{ text: '部署', link: '/deploy' },
|
||||
{ text: '贡献', link: '/contributing' }
|
||||
],
|
||||
|
||||
sidebar: {
|
||||
'/': [
|
||||
{
|
||||
text: '指南',
|
||||
items: [
|
||||
{ text: '介绍', link: '/' },
|
||||
{ text: '快速开始', link: '/getting-started' },
|
||||
{ text: '配置', link: '/config' },
|
||||
{ text: '部署指南', link: '/deploy' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'API文档',
|
||||
items: [
|
||||
{ text: 'API交互式文档', link: '/api/' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '其他',
|
||||
items: [
|
||||
{ text: '贡献指南', link: '/contributing' },
|
||||
{ text: '免责声明', link: '/disclaimer' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
socialLinks: [
|
||||
{ icon: 'github', link: 'https://github.com/LoveACE-Team/LoveACE' }
|
||||
],
|
||||
|
||||
footer: {
|
||||
message: '基于 MIT 许可发布',
|
||||
copyright: 'Copyright © 2025 LoveACE'
|
||||
},
|
||||
|
||||
search: {
|
||||
provider: 'local'
|
||||
},
|
||||
|
||||
lastUpdated: {
|
||||
text: '最后更新于',
|
||||
formatOptions: {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
head: [
|
||||
['link', { rel: 'icon', href: '/images/logo.jpg' }]
|
||||
],
|
||||
|
||||
markdown: {
|
||||
lineNumbers: true
|
||||
}
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
/* 自定义样式文件 */
|
||||
|
||||
/* 确保SwaggerUI容器有足够的高度 */
|
||||
.swagger-container {
|
||||
min-height: 600px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* SwaggerUI组件的容器样式 */
|
||||
.api-docs-container {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* 为API文档页面添加特殊样式 */
|
||||
.api-page .content-container {
|
||||
max-width: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 960px) {
|
||||
.api-docs-container {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { h } from 'vue'
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import SwaggerUI from '../components/SwaggerUI.vue'
|
||||
import './custom.css'
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout: () => {
|
||||
return h(DefaultTheme.Layout, null, {
|
||||
// https://vitepress.dev/guide/extending-default-theme#layout-slots
|
||||
})
|
||||
},
|
||||
enhanceApp({ app, router, siteData }) {
|
||||
// 注册全局组件
|
||||
app.component('SwaggerUI', SwaggerUI)
|
||||
}
|
||||
}
|
||||
46
docs/API_DOCSTRING_GUIDELINE.md
Normal file
46
docs/API_DOCSTRING_GUIDELINE.md
Normal file
@@ -0,0 +1,46 @@
|
||||
## 注解使用指南
|
||||
|
||||
### 📝 写注解时的最佳实践
|
||||
|
||||
1. **保持一致性** - 所有注解使用统一的结构和 Emoji
|
||||
2. **包含场景** - 明确说明接口的应用场景
|
||||
3. **突出特性** - 使用 ✅ 标记主要功能
|
||||
4. **警告限制** - 使用 ⚠️ 标记重要限制条件
|
||||
5. **简明扼要** - 避免过长的描述,保持可读性
|
||||
|
||||
### 🎯 Emoji 参考表
|
||||
|
||||
| Emoji | 含义 | 用途 |
|
||||
|--------|------|------|
|
||||
| ✅ | 功能特性 | 列举该接口的主要优势 |
|
||||
| ⚠️ | 警告/限制 | 标记使用时需要注意的限制 |
|
||||
| 💡 | 建议/场景 | 列举应用场景或建议 |
|
||||
| 🔄 | 流程/步骤 | 表示流程或步骤 |
|
||||
| 🎁 | 返回值 | 描述返回值 |
|
||||
|
||||
```python
|
||||
"""
|
||||
[简明功能描述]
|
||||
|
||||
✅ 功能特性:
|
||||
- 功能 1
|
||||
- 功能 2
|
||||
- 功能 3
|
||||
|
||||
⚠️ 限制条件:(如需要)
|
||||
- 限制 1
|
||||
- 限制 2
|
||||
|
||||
💡 使用场景:
|
||||
- 场景 1
|
||||
- 场景 2
|
||||
- 场景 3
|
||||
|
||||
Args:
|
||||
param1: 参数说明
|
||||
param2: 参数说明
|
||||
|
||||
Returns:
|
||||
ResponseType: 返回值说明
|
||||
"""
|
||||
```
|
||||
326
docs/ISIM_API.md
326
docs/ISIM_API.md
@@ -1,326 +0,0 @@
|
||||
# ISIM 电费查询系统 API 文档
|
||||
|
||||
## 概述
|
||||
|
||||
ISIM(Integrated Student Information Management)电费查询系统是为安徽财经大学学生提供的后勤电费查询服务。通过该系统,学生可以:
|
||||
|
||||
- 选择和绑定宿舍房间
|
||||
- 查询电费余额和用电记录
|
||||
- 查看充值记录
|
||||
|
||||
## API 端点
|
||||
|
||||
### 认证
|
||||
|
||||
所有API都需要通过认证令牌(authme_token)进行身份验证。认证信息通过依赖注入自动处理。
|
||||
|
||||
### 房间选择器 API
|
||||
|
||||
#### 1. 获取楼栋列表
|
||||
|
||||
**POST** `/api/v1/isim/picker/building/get`
|
||||
|
||||
获取所有可选择的楼栋信息。
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "楼栋列表获取成功",
|
||||
"data": [
|
||||
{
|
||||
"code": "11",
|
||||
"name": "北苑11号学生公寓"
|
||||
},
|
||||
{
|
||||
"code": "12",
|
||||
"name": "北苑12号学生公寓"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 设置楼栋并获取楼层列表
|
||||
|
||||
**POST** `/api/v1/isim/picker/building/set`
|
||||
|
||||
设置楼栋并获取对应的楼层列表。
|
||||
|
||||
**请求参数:**
|
||||
```json
|
||||
{
|
||||
"building_code": "11"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "楼层列表获取成功",
|
||||
"data": [
|
||||
{
|
||||
"code": "010101",
|
||||
"name": "1-1层"
|
||||
},
|
||||
{
|
||||
"code": "010102",
|
||||
"name": "1-2层"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 设置楼层并获取房间列表
|
||||
|
||||
**POST** `/api/v1/isim/picker/floor/set`
|
||||
|
||||
设置楼层并获取对应的房间列表。
|
||||
|
||||
**请求参数:**
|
||||
```json
|
||||
{
|
||||
"floor_code": "010101"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "房间列表获取成功",
|
||||
"data": [
|
||||
{
|
||||
"code": "01",
|
||||
"name": "1-101"
|
||||
},
|
||||
{
|
||||
"code": "02",
|
||||
"name": "1-102"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 绑定房间
|
||||
|
||||
**POST** `/api/v1/isim/picker/room/set`
|
||||
|
||||
绑定房间到用户账户。
|
||||
|
||||
**请求参数:**
|
||||
```json
|
||||
{
|
||||
"building_code": "11",
|
||||
"floor_code": "010101",
|
||||
"room_code": "01"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "房间绑定成功",
|
||||
"data": {
|
||||
"building": {
|
||||
"code": "11",
|
||||
"name": "北苑11号学生公寓"
|
||||
},
|
||||
"floor": {
|
||||
"code": "010101",
|
||||
"name": "1-1层"
|
||||
},
|
||||
"room": {
|
||||
"code": "01",
|
||||
"name": "1-101"
|
||||
},
|
||||
"room_id": "01",
|
||||
"display_text": "北苑11号学生公寓/1-1层/1-101"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 电费查询 API
|
||||
|
||||
#### 5. 获取电费信息
|
||||
|
||||
**POST** `/api/v1/isim/electricity/info`
|
||||
|
||||
获取电费余额和用电记录信息。
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "电费信息获取成功",
|
||||
"data": {
|
||||
"balance": {
|
||||
"remaining_purchased": 815.30,
|
||||
"remaining_subsidy": 2198.01
|
||||
},
|
||||
"usage_records": [
|
||||
{
|
||||
"record_time": "2025-08-29 00:04:58",
|
||||
"usage_amount": 0.00,
|
||||
"meter_name": "1-101"
|
||||
},
|
||||
{
|
||||
"record_time": "2025-08-29 00:04:58",
|
||||
"usage_amount": 0.00,
|
||||
"meter_name": "1-101空调"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 6. 获取充值信息
|
||||
|
||||
**POST** `/api/v1/isim/payment/info`
|
||||
|
||||
获取电费余额和充值记录信息。
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "充值信息获取成功",
|
||||
"data": {
|
||||
"balance": {
|
||||
"remaining_purchased": 815.30,
|
||||
"remaining_subsidy": 2198.01
|
||||
},
|
||||
"payment_records": [
|
||||
{
|
||||
"payment_time": "2025-02-21 11:30:08",
|
||||
"amount": 71.29,
|
||||
"payment_type": "下发补助"
|
||||
},
|
||||
{
|
||||
"payment_time": "2024-09-01 15:52:40",
|
||||
"amount": 71.29,
|
||||
"payment_type": "下发补助"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 7. 检查房间绑定状态
|
||||
|
||||
**POST** `/api/v1/isim/room/binding/status`
|
||||
|
||||
检查用户是否已绑定宿舍房间。
|
||||
|
||||
**已绑定响应示例:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "用户已绑定宿舍房间",
|
||||
"data": {
|
||||
"is_bound": true,
|
||||
"binding_info": {
|
||||
"building": {
|
||||
"code": "35",
|
||||
"name": "西校荆苑5号学生公寓"
|
||||
},
|
||||
"floor": {
|
||||
"code": "3501",
|
||||
"name": "荆5-1层"
|
||||
},
|
||||
"room": {
|
||||
"code": "350116",
|
||||
"name": "J5-116"
|
||||
},
|
||||
"room_id": "350116",
|
||||
"display_text": "西校荆苑5号学生公寓/荆5-1层/J5-116"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**未绑定响应示例:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "用户未绑定宿舍房间",
|
||||
"data": {
|
||||
"is_bound": false,
|
||||
"binding_info": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 标准错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"message": "错误描述信息"
|
||||
}
|
||||
```
|
||||
|
||||
### 认证错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 401,
|
||||
"message": "Cookie已失效或不在VPN/校园网环境,请重新登录"
|
||||
}
|
||||
```
|
||||
|
||||
### 常见错误代码
|
||||
|
||||
- `0`: 成功
|
||||
- `1`: 一般业务错误
|
||||
- `400`: 请求参数错误或未绑定房间
|
||||
- `401`: 认证失败
|
||||
- `500`: 服务器内部错误
|
||||
|
||||
### 特殊错误情况
|
||||
|
||||
#### 未绑定房间错误
|
||||
|
||||
当用户尝试查询电费或充值信息但未绑定房间时,会返回特定错误:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "请先绑定宿舍房间后再查询电费信息"
|
||||
}
|
||||
```
|
||||
|
||||
或
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "请先绑定宿舍房间后再查询充值信息"
|
||||
}
|
||||
```
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 房间绑定流程
|
||||
1. **检查绑定状态**:调用房间绑定状态API检查是否已绑定
|
||||
2. **首次绑定**(如果未绑定):
|
||||
- 调用楼栋列表API获取所有楼栋
|
||||
- 调用楼栋设置API获取楼层列表
|
||||
- 调用楼层设置API获取房间列表
|
||||
- 调用房间绑定API完成房间绑定
|
||||
|
||||
### 查询流程
|
||||
1. **确认绑定**:确保用户已绑定房间(必需)
|
||||
2. **查询信息**:调用电费信息或充值信息API获取数据
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 所有接口都需要有效的认证令牌
|
||||
- 数据实时从后勤系统获取,不会在数据库中缓存
|
||||
- 房间绑定信息会保存在数据库中以便下次使用
|
||||
- 系统需要VPN或校园网环境才能正常访问
|
||||
- **电费查询和充值查询需要先绑定房间**,否则会返回400错误
|
||||
- 访问`/go`端点会返回302重定向,系统会自动处理并提取JSESSIONID
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
layout: page
|
||||
title: LoveACE API 文档
|
||||
description: 基于 OpenAPI 3.1 规范的交互式 API 文档
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
onMounted(() => {
|
||||
// 为当前页面添加特殊的CSS类,用于样式定制
|
||||
document.body.classList.add('api-page')
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="api-docs-container">
|
||||
<SwaggerUI />
|
||||
</div>
|
||||
232
docs/config.md
232
docs/config.md
@@ -1,232 +0,0 @@
|
||||
# 配置指南
|
||||
|
||||
LoveACE使用JSON格式的配置文件来管理各种设置。本文档详细介绍了所有可用的配置选项。
|
||||
|
||||
## 配置文件位置
|
||||
|
||||
配置文件应位于项目根目录下,命名为`config.json`。您可以从`config.example.json`复制并修改。
|
||||
|
||||
## 完整配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"database": {
|
||||
"url": "mysql+aiomysql://username:password@host:port/database",
|
||||
"echo": false,
|
||||
"pool_size": 10,
|
||||
"max_overflow": 20,
|
||||
"pool_timeout": 30,
|
||||
"pool_recycle": 3600
|
||||
},
|
||||
"aufe": {
|
||||
"default_timeout": 30,
|
||||
"max_retries": 3,
|
||||
"max_reconnect_retries": 2,
|
||||
"activity_timeout": 300,
|
||||
"monitor_interval": 60,
|
||||
"retry_base_delay": 1.0,
|
||||
"retry_max_delay": 60.0,
|
||||
"retry_exponential_base": 2.0,
|
||||
"uaap_base_url": "http://uaap-aufe-edu-cn.vpn2.aufe.edu.cn:8118/cas",
|
||||
"uaap_login_url": "http://uaap-aufe-edu-cn.vpn2.aufe.edu.cn:8118/cas/login?service=http%3A%2F%2Fjwcxk2.aufe.edu.cn%2Fj_spring_cas_security_check",
|
||||
"default_headers": {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||
}
|
||||
},
|
||||
"s3": {
|
||||
"access_key_id": "YOUR_ACCESS_KEY_ID",
|
||||
"secret_access_key": "YOUR_SECRET_ACCESS_KEY",
|
||||
"endpoint_url": null,
|
||||
"region_name": "us-east-1",
|
||||
"bucket_name": "YOUR_BUCKET_NAME",
|
||||
"use_ssl": true,
|
||||
"signature_version": "s3v4"
|
||||
},
|
||||
"log": {
|
||||
"level": "INFO",
|
||||
"format": "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
||||
"file_path": "logs/app.log",
|
||||
"rotation": "10 MB",
|
||||
"retention": "30 days",
|
||||
"compression": "zip",
|
||||
"backtrace": true,
|
||||
"diagnose": true,
|
||||
"console_output": true,
|
||||
"additional_loggers": [
|
||||
{
|
||||
"file_path": "logs/debug.log",
|
||||
"level": "DEBUG",
|
||||
"rotation": "10 MB"
|
||||
},
|
||||
{
|
||||
"file_path": "logs/error.log",
|
||||
"level": "ERROR",
|
||||
"rotation": "10 MB"
|
||||
}
|
||||
]
|
||||
},
|
||||
"app": {
|
||||
"title": "LoveAC API",
|
||||
"description": "LoveACAPI API",
|
||||
"version": "1.0.0",
|
||||
"debug": false,
|
||||
"cors_allow_origins": ["*"],
|
||||
"cors_allow_credentials": true,
|
||||
"cors_allow_methods": ["*"],
|
||||
"cors_allow_headers": ["*"],
|
||||
"host": "0.0.0.0",
|
||||
"port": 8000,
|
||||
"workers": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 配置项详解
|
||||
|
||||
### 数据库配置 (database)
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `url` | string | - | 数据库连接URL,支持MySQL、SQLite等 |
|
||||
| `echo` | boolean | false | 是否打印SQL语句到日志 |
|
||||
| `pool_size` | integer | 10 | 连接池大小 |
|
||||
| `max_overflow` | integer | 20 | 连接池最大溢出数量 |
|
||||
| `pool_timeout` | integer | 30 | 获取连接超时时间(秒) |
|
||||
| `pool_recycle` | integer | 3600 | 连接回收时间(秒) |
|
||||
|
||||
#### 数据库URL格式
|
||||
|
||||
**MySQL**:
|
||||
```
|
||||
mysql+aiomysql://用户名:密码@主机:端口/数据库名
|
||||
```
|
||||
|
||||
**SQLite**:
|
||||
```
|
||||
sqlite+aiosqlite:///path/to/database.db
|
||||
```
|
||||
|
||||
### AUFE配置 (aufe)
|
||||
|
||||
安徽财经大学教务系统相关配置。
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `default_timeout` | integer | 30 | 默认请求超时时间(秒) |
|
||||
| `max_retries` | integer | 3 | 最大重试次数 |
|
||||
| `max_reconnect_retries` | integer | 2 | 最大重连次数 |
|
||||
| `activity_timeout` | integer | 300 | 活动超时时间(秒) |
|
||||
| `monitor_interval` | integer | 60 | 监控间隔(秒) |
|
||||
| `retry_base_delay` | float | 1.0 | 重试基础延迟(秒) |
|
||||
| `retry_max_delay` | float | 60.0 | 重试最大延迟(秒) |
|
||||
| `retry_exponential_base` | float | 2.0 | 重试指数基数 |
|
||||
| `uaap_base_url` | string | - | UAAP基础URL |
|
||||
| `uaap_login_url` | string | - | UAAP登录URL |
|
||||
| `default_headers` | object | - | 默认HTTP请求头 |
|
||||
|
||||
### S3存储配置 (s3)
|
||||
|
||||
用于文件存储的S3兼容服务配置。
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `access_key_id` | string | - | S3访问密钥ID |
|
||||
| `secret_access_key` | string | - | S3访问密钥 |
|
||||
| `endpoint_url` | string | null | 自定义端点URL(用于S3兼容服务) |
|
||||
| `region_name` | string | us-east-1 | 区域名称 |
|
||||
| `bucket_name` | string | - | 存储桶名称 |
|
||||
| `use_ssl` | boolean | true | 是否使用SSL |
|
||||
| `signature_version` | string | s3v4 | 签名版本 |
|
||||
|
||||
### 日志配置 (log)
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `level` | string | INFO | 日志级别 |
|
||||
| `format` | string | - | 日志格式 |
|
||||
| `file_path` | string | logs/app.log | 主日志文件路径 |
|
||||
| `rotation` | string | 10 MB | 日志轮转大小 |
|
||||
| `retention` | string | 30 days | 日志保留时间 |
|
||||
| `compression` | string | zip | 压缩格式 |
|
||||
| `backtrace` | boolean | true | 是否包含回溯信息 |
|
||||
| `diagnose` | boolean | true | 是否包含诊断信息 |
|
||||
| `console_output` | boolean | true | 是否输出到控制台 |
|
||||
| `additional_loggers` | array | - | 额外的日志记录器配置 |
|
||||
|
||||
#### 日志级别
|
||||
|
||||
- `DEBUG`: 调试信息
|
||||
- `INFO`: 一般信息
|
||||
- `WARNING`: 警告信息
|
||||
- `ERROR`: 错误信息
|
||||
- `CRITICAL`: 严重错误
|
||||
|
||||
### 应用配置 (app)
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `title` | string | LoveAC API | 应用标题 |
|
||||
| `description` | string | - | 应用描述 |
|
||||
| `version` | string | 1.0.0 | 应用版本 |
|
||||
| `debug` | boolean | false | 是否启用调试模式 |
|
||||
| `cors_allow_origins` | array | ["*"] | 允许的CORS源 |
|
||||
| `cors_allow_credentials` | boolean | true | 是否允许携带凭证 |
|
||||
| `cors_allow_methods` | array | ["*"] | 允许的HTTP方法 |
|
||||
| `cors_allow_headers` | array | ["*"] | 允许的HTTP头 |
|
||||
| `host` | string | 0.0.0.0 | 绑定主机 |
|
||||
| `port` | integer | 8000 | 绑定端口 |
|
||||
| `workers` | integer | 1 | 工作进程数 |
|
||||
|
||||
## 环境特定配置
|
||||
|
||||
### 开发环境
|
||||
|
||||
```json
|
||||
{
|
||||
"app": {
|
||||
"debug": true,
|
||||
"workers": 1
|
||||
},
|
||||
"log": {
|
||||
"level": "DEBUG",
|
||||
"console_output": true
|
||||
},
|
||||
"database": {
|
||||
"echo": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 生产环境
|
||||
|
||||
```json
|
||||
{
|
||||
"app": {
|
||||
"debug": false,
|
||||
"workers": 4,
|
||||
"cors_allow_origins": ["https://yourdomain.com"]
|
||||
},
|
||||
"log": {
|
||||
"level": "INFO",
|
||||
"console_output": false
|
||||
},
|
||||
"database": {
|
||||
"echo": false,
|
||||
"pool_size": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 配置验证
|
||||
|
||||
启动应用时,系统会自动验证配置文件的格式和必需参数。如果配置有误,应用将无法启动并显示相应的错误信息。
|
||||
|
||||
## 动态配置
|
||||
|
||||
某些配置项支持运行时修改,无需重启服务:
|
||||
|
||||
- 日志级别
|
||||
- CORS设置
|
||||
- 部分AUFE配置
|
||||
|
||||
动态配置修改可通过管理API进行(需要管理员权限)。
|
||||
@@ -1,5 +0,0 @@
|
||||
# 贡献指南
|
||||
|
||||
感谢您对LoveACE项目的关注!我们欢迎所有形式的贡献,包括但不限于代码贡献、文档改进、问题报告和功能建议。
|
||||
|
||||
## In Progress
|
||||
@@ -1,5 +0,0 @@
|
||||
# 部署指南
|
||||
|
||||
本指南介绍如何在生产环境中部署LoveACE教务系统自动化工具。
|
||||
|
||||
## In Progress
|
||||
@@ -1,119 +0,0 @@
|
||||
# 免责声明
|
||||
|
||||
## 重要声明
|
||||
|
||||
**请在使用LoveACE(以下简称"本软件")之前仔细阅读本免责声明。使用本软件即表示您已阅读、理解并同意接受本声明的所有条款。**
|
||||
|
||||
## 软件性质与用途
|
||||
|
||||
1. **教育目的**: 本软件是为教育和学习目的而开发的开源项目,旨在帮助学生简化教务系统操作流程。
|
||||
|
||||
2. **个人使用**: 本软件仅供个人学习和使用,不得用于任何商业目的。
|
||||
|
||||
3. **实验性质**: 本软件处于开发阶段,可能存在功能不完善、数据不准确等问题。
|
||||
|
||||
## 使用风险与责任
|
||||
|
||||
### 用户责任
|
||||
|
||||
1. **合规使用**: 用户有责任确保使用本软件的行为符合所在地区的法律法规以及学校的相关规定。
|
||||
|
||||
2. **账户安全**: 用户应妥善保管自己的账户信息,因账户信息泄露造成的损失由用户自行承担。
|
||||
|
||||
3. **数据备份**: 用户应自行备份重要数据,开发者不对数据丢失承担责任。
|
||||
|
||||
4. **风险评估**: 用户在使用本软件前应充分评估可能的风险,并自行决定是否使用。
|
||||
|
||||
### 开发者免责
|
||||
|
||||
1. **不保证性**: 开发者不保证本软件的功能完整性、准确性、可靠性或及时性。
|
||||
|
||||
2. **服务中断**: 开发者不对因软件故障、网络问题、服务器维护等原因导致的服务中断承担责任。
|
||||
|
||||
3. **数据损失**: 开发者不对使用本软件过程中可能出现的数据丢失、损坏或泄露承担责任。
|
||||
|
||||
4. **第三方影响**: 开发者不对因第三方服务(如学校教务系统)变更而导致的软件功能异常承担责任。
|
||||
|
||||
## 技术限制
|
||||
|
||||
1. **兼容性**: 本软件可能无法与所有系统环境兼容,用户应在支持的环境中使用。
|
||||
|
||||
2. **性能表现**: 软件的性能表现可能因硬件配置、网络环境等因素而有所差异。
|
||||
|
||||
3. **功能限制**: 本软件的功能可能受到目标系统的限制,某些功能可能无法正常使用。
|
||||
|
||||
## 隐私与数据安全
|
||||
|
||||
1. **数据收集**: 本软件可能收集必要的用户数据以提供服务,但不会收集与服务无关的个人信息。
|
||||
|
||||
2. **数据存储**: 用户数据存储在用户自行配置的数据库中,开发者不保存用户的敏感信息。
|
||||
|
||||
3. **数据传输**: 数据传输过程中可能存在被截获的风险,用户应采取适当的安全措施。
|
||||
|
||||
4. **第三方访问**: 开发者承诺不会主动向第三方泄露用户数据,但不能保证在所有情况下数据的绝对安全。
|
||||
|
||||
## 法律合规
|
||||
|
||||
1. **遵守法律**: 用户使用本软件时应遵守所在地区的相关法律法规。
|
||||
|
||||
2. **学校规定**: 用户应确保使用本软件的行为符合所在学校(此处指安徽财经大学)的规章制度(详阅最新版安徽财经大学本科生学生手册)。
|
||||
|
||||
3. **禁止行为**:
|
||||
- 不得使用本软件进行任何违法活动
|
||||
- 不得利用本软件进行恶意攻击或破坏行为
|
||||
- 不得将本软件用于商业目的
|
||||
- 不得传播或分享他人的账户信息
|
||||
|
||||
## 知识产权
|
||||
|
||||
1. **开源许可**: 本软件基于MIT许可证开源,用户应遵守相关许可条款。
|
||||
|
||||
2. **版权声明**: 本软件的版权归原作者所有,未经授权不得用于商业用途。
|
||||
|
||||
3. **商标权**: 涉及的第三方商标权归其所有者所有,本软件的使用不代表对这些商标的任何权利主张。
|
||||
|
||||
## 服务变更与终止
|
||||
|
||||
1. **功能变更**: 开发者保留随时修改、升级或终止软件功能的权利,恕不另行通知。
|
||||
|
||||
2. **服务终止**: 开发者可能因技术、法律或其他原因终止软件服务,用户应提前做好数据备份。
|
||||
|
||||
3. **协议更新**: 本免责声明可能随时更新,建议用户定期查看最新版本。
|
||||
|
||||
## 争议解决
|
||||
|
||||
1. **友好协商**: 因使用本软件产生的争议,双方应首先通过友好协商解决。
|
||||
|
||||
2. **法律途径**: 如协商无果,争议应按照开发者所在地的法律法规通过法律途径解决。
|
||||
|
||||
## 紧急情况处理
|
||||
|
||||
如果在使用过程中遇到以下情况,请立即停止使用:
|
||||
|
||||
1. 收到学校或相关部门的警告
|
||||
2. 发现账户异常或疑似被盗用
|
||||
3. 软件出现严重错误或异常行为
|
||||
4. 怀疑数据泄露或安全问题
|
||||
|
||||
## 联系方式
|
||||
|
||||
如果您对本免责声明有任何疑问,或在使用过程中遇到问题,请通过以下方式联系:
|
||||
|
||||
- **邮箱**: sibuxiang@proton.me
|
||||
- **GitHub Issues**: [https://github.com/LoveACE-Team/LoveACE/issues](https://github.com/LoveACE-Team/LoveACE/issues)
|
||||
|
||||
## 最终条款
|
||||
|
||||
1. **完整协议**: 本免责声明构成完整的协议,取代之前的所有口头或书面协议。
|
||||
|
||||
2. **协议效力**: 如本协议的任何条款被认定为无效或不可执行,其余条款仍然有效。
|
||||
|
||||
3. **生效时间**: 本免责声明自用户首次使用本软件时生效。
|
||||
|
||||
---
|
||||
|
||||
**最后更新时间**: 2025/8/3
|
||||
|
||||
**版本**: v1.0
|
||||
|
||||
**请注意**: 本免责声明可能会不定期更新,继续使用本软件即表示您接受更新后的条款。
|
||||
@@ -1,96 +0,0 @@
|
||||
# 快速开始
|
||||
|
||||
本指南将帮助您快速设置并运行LoveACE教务系统自动化工具。
|
||||
|
||||
## 前置条件
|
||||
|
||||
在开始之前,请确保您的系统已安装:
|
||||
|
||||
- **Python 3.12**
|
||||
- **PDM** (Python Dependency Manager)
|
||||
- **MySQL** 或其他支持的数据库
|
||||
|
||||
## 安装步骤
|
||||
|
||||
### 1. 克隆项目
|
||||
|
||||
```bash
|
||||
git clone https://github.com/LoveACE-Team/LoveACE.git
|
||||
cd LoveACE
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
使用PDM安装项目依赖:
|
||||
|
||||
```bash
|
||||
pdm install
|
||||
```
|
||||
|
||||
### 3. 配置环境
|
||||
|
||||
启动 App 生成配置文件并编辑:
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
编辑`config.json`文件,配置以下关键参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"database": {
|
||||
"url": "mysql+aiomysql://username:password@host:port/database"
|
||||
},
|
||||
"app": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 初始化数据库
|
||||
|
||||
项目会在首次运行时自动创建数据库表结构。
|
||||
|
||||
### 5. 启动服务
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- 查看 [配置指南](/config) 了解详细配置选项
|
||||
- 阅读 [API文档](/api/) 了解可用接口
|
||||
- 参考 [部署指南](/deploy) 进行生产环境部署
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 数据库连接失败
|
||||
|
||||
检查`config.json`中的数据库配置是否正确,确保:
|
||||
- 数据库服务已启动
|
||||
- 用户名密码正确
|
||||
- 网络连接正常
|
||||
|
||||
### 端口被占用
|
||||
|
||||
如果8000端口被占用,可以在配置文件中修改端口:
|
||||
|
||||
```json
|
||||
{
|
||||
"app": {
|
||||
"port": 8080
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 依赖安装失败
|
||||
|
||||
确保使用Python 3.12,并尝试清理缓存:
|
||||
|
||||
```bash
|
||||
pdm cache clear
|
||||
pdm install
|
||||
```
|
||||
@@ -1,91 +0,0 @@
|
||||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: "LoveACE"
|
||||
text: "教务系统自动化工具"
|
||||
tagline: "简化学生教务操作,提高使用效率"
|
||||
image:
|
||||
src: /images/logo.jpg
|
||||
alt: LoveACE Logo
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 快速开始
|
||||
link: /getting-started
|
||||
- theme: alt
|
||||
text: API文档
|
||||
link: /api/
|
||||
|
||||
features:
|
||||
- icon: 🔐
|
||||
title: 用户认证与授权
|
||||
details: 支持邀请码注册和用户登录,确保系统安全
|
||||
- icon: 📚
|
||||
title: 教务系统集成
|
||||
details: 学业信息查询、培养方案信息查询、课程列表查询
|
||||
- icon: ⭐
|
||||
title: 自动评教系统(开发中)
|
||||
details: 支持评教任务的初始化、开始、暂停、终止和状态查询
|
||||
- icon: 💯
|
||||
title: 爱安财系统
|
||||
details: 总分信息查询和分数明细列表查询
|
||||
- icon: 🚀
|
||||
title: 高性能架构
|
||||
details: 基于FastAPI和异步SQLAlchemy构建,支持高并发访问
|
||||
- icon: 📖
|
||||
title: 完整文档
|
||||
details: 提供详细的API文档、配置指南和部署教程
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端框架**: FastAPI
|
||||
- **数据库ORM**: SQLAlchemy (异步)
|
||||
- **HTTP客户端**: 基于aiohttp的自定义客户端
|
||||
- **日志系统**: richuru (rich + loguru)
|
||||
|
||||
## 快速体验
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/LoveACE-Team/LoveACE.git
|
||||
cd LoveACE
|
||||
|
||||
# 安装依赖
|
||||
pdm install
|
||||
|
||||
# 配置数据库
|
||||
启动 App 生成配置文件并编辑:
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
编辑`config.json`文件,配置以下关键参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"database": {
|
||||
"url": "mysql+aiomysql://username:password@host:port/database"
|
||||
},
|
||||
"app": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8000
|
||||
}
|
||||
}
|
||||
|
||||
# 启动服务
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
## 社区
|
||||
|
||||
如果您有任何问题或建议,欢迎:
|
||||
|
||||
- 📝 [提交Issue](https://github.com/LoveACE-Team/LoveACE/issues)
|
||||
- 🔀 [发起Pull Request](https://github.com/LoveACE-Team/LoveACE/pulls)
|
||||
- 💬 加入讨论组
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 MIT 许可证开源。详情请查看 [LICENSE](https://github.com/LoveACE-Team/LoveACE/blob/main/LICENSE) 文件。
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
2758
docs/public/openapi.json
generated
2758
docs/public/openapi.json
generated
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
474
encrypt_cli.py
Normal file
474
encrypt_cli.py
Normal file
@@ -0,0 +1,474 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RSA 密钥文件管理工具
|
||||
支持:
|
||||
1. 将 .pem 格式的密钥文件加密为 .hex 格式(使用 AES-GCM-SIV 加密)
|
||||
2. 修改已加密密钥的密码
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCMSIV
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import Prompt
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def derive_key_from_password(
|
||||
password: str, salt: bytes | None = None
|
||||
) -> tuple[bytes, bytes]:
|
||||
"""从密码派生 AES 密钥
|
||||
|
||||
Args:
|
||||
password (str): 用户输入的密码
|
||||
salt (bytes): 盐值,如果为 None 则生成新的
|
||||
|
||||
Returns:
|
||||
tuple[bytes, bytes]: (派生密钥, 盐值)
|
||||
"""
|
||||
if salt is None:
|
||||
salt = os.urandom(16)
|
||||
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=16, # AES-128 需要 16 字节密钥
|
||||
salt=salt,
|
||||
iterations=100000,
|
||||
)
|
||||
key = kdf.derive(password.encode("utf-8"))
|
||||
return key, salt
|
||||
|
||||
|
||||
def encrypt_pem_file(pem_file_path: str, password: str) -> str:
|
||||
"""加密 PEM 文件并保存为 .hex 格式
|
||||
|
||||
Args:
|
||||
pem_file_path (str): PEM 文件路径
|
||||
password (str): 密码
|
||||
|
||||
Returns:
|
||||
str: 保存的 .hex 文件路径
|
||||
"""
|
||||
pem_path = Path(pem_file_path)
|
||||
|
||||
# 读取 PEM 文件
|
||||
if not pem_path.exists():
|
||||
console.print(f"[red]✗ 文件不存在: {pem_file_path}[/red]")
|
||||
return ""
|
||||
|
||||
with open(pem_path, "rb") as f:
|
||||
plaintext = f.read()
|
||||
|
||||
# 派生密钥并加密
|
||||
key, salt = derive_key_from_password(password)
|
||||
aesgcmsiv = AESGCMSIV(key)
|
||||
nonce = os.urandom(12)
|
||||
ciphertext = aesgcmsiv.encrypt(nonce, plaintext, None)
|
||||
|
||||
# 生成 .hex 文件路径
|
||||
hex_path = str(pem_path).replace(".pem", ".hex")
|
||||
|
||||
# 保存加密数据:salt + nonce + ciphertext
|
||||
with open(hex_path, "wb") as f:
|
||||
f.write(salt + nonce + ciphertext)
|
||||
|
||||
return hex_path
|
||||
|
||||
|
||||
def find_all_key_files(search_dir: str = ".") -> tuple[list[Path], list[Path]]:
|
||||
"""检索项目中的所有密钥文件
|
||||
|
||||
Args:
|
||||
search_dir (str): 搜索目录,默认为当前目录
|
||||
|
||||
Returns:
|
||||
tuple[list[Path], list[Path]]: (.pem 文件列表, .hex 文件列表)
|
||||
"""
|
||||
search_path = Path(search_dir)
|
||||
pem_files = []
|
||||
hex_files = []
|
||||
|
||||
for pem_file in search_path.rglob("*.pem"):
|
||||
# 排除备份文件
|
||||
if not pem_file.name.endswith(".backup"):
|
||||
pem_files.append(pem_file)
|
||||
|
||||
for hex_file in search_path.rglob("*.hex"):
|
||||
hex_files.append(hex_file)
|
||||
|
||||
return pem_files, hex_files
|
||||
|
||||
|
||||
def change_key_password(hex_file_path: str):
|
||||
"""修改已加密密钥的密码
|
||||
|
||||
Args:
|
||||
hex_file_path (str): .hex 密钥文件路径
|
||||
"""
|
||||
hex_path = Path(hex_file_path)
|
||||
|
||||
if not hex_path.exists():
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold red]✗ 文件不存在: {hex_file_path}[/bold red]",
|
||||
title="[bold red]错误[/bold red]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# 读取加密的文件
|
||||
with open(hex_path, "rb") as f:
|
||||
encrypted_data = f.read()
|
||||
|
||||
# 解析加密数据:salt(16) + nonce(12) + ciphertext
|
||||
salt = encrypted_data[:16]
|
||||
nonce = encrypted_data[16:28]
|
||||
ciphertext = encrypted_data[28:]
|
||||
|
||||
# 请求旧密码
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold cyan]请输入当前密码以验证[/bold cyan]",
|
||||
title="[bold blue]验证密钥[/bold blue]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
old_password = Prompt.ask(
|
||||
"[bold]请输入当前密码[/bold]", password=True, console=console
|
||||
)
|
||||
|
||||
# 验证旧密码
|
||||
try:
|
||||
old_key, _ = derive_key_from_password(old_password, salt)
|
||||
aesgcmsiv = AESGCMSIV(old_key)
|
||||
plaintext = aesgcmsiv.decrypt(nonce, ciphertext, None)
|
||||
console.print("[bold green]✓ 密码验证成功[/bold green]")
|
||||
except Exception:
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold red]✗ 密码错误或密钥文件已损坏[/bold red]",
|
||||
title="[bold red]错误[/bold red]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# 设置新密码
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold cyan]请设置新密码[/bold cyan]",
|
||||
title="[bold blue]设置新密码[/bold blue]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
new_password = Prompt.ask(
|
||||
"[bold]请输入新密码[/bold]", password=True, console=console
|
||||
)
|
||||
new_password_confirm = Prompt.ask(
|
||||
"[bold]请确认新密码[/bold]", password=True, console=console
|
||||
)
|
||||
|
||||
if new_password != new_password_confirm:
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold red]✗ 两次输入的密码不一致[/bold red]",
|
||||
title="[bold red]错误[/bold red]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if new_password == old_password:
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold yellow]⚠ 新密码与旧密码相同,无需修改[/bold yellow]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用新密码重新加密
|
||||
console.print("[bold cyan]正在重新加密文件...[/bold cyan]")
|
||||
new_key, new_salt = derive_key_from_password(new_password)
|
||||
new_aesgcmsiv = AESGCMSIV(new_key)
|
||||
new_nonce = os.urandom(12)
|
||||
new_ciphertext = new_aesgcmsiv.encrypt(new_nonce, plaintext, None)
|
||||
|
||||
# 保存新的加密数据
|
||||
with open(hex_path, "wb") as f:
|
||||
f.write(new_salt + new_nonce + new_ciphertext)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold green]✓ 密钥密码修改成功[/bold green]",
|
||||
title="[bold blue]完成[/bold blue]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main_menu():
|
||||
"""主菜单"""
|
||||
while True:
|
||||
console.clear()
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold cyan]RSA 密钥文件管理工具[/bold cyan]",
|
||||
title="[bold blue]主菜单[/bold blue]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
console.print()
|
||||
menu_options = [
|
||||
"1. 加密 PEM 密钥文件",
|
||||
"2. 修改密钥密码",
|
||||
"3. 退出",
|
||||
]
|
||||
|
||||
for option in menu_options:
|
||||
console.print(f" {option}")
|
||||
|
||||
console.print()
|
||||
choice = Prompt.ask(
|
||||
"[bold]请选择操作[/bold]",
|
||||
choices=["1", "2", "3"],
|
||||
console=console,
|
||||
)
|
||||
|
||||
if choice == "1":
|
||||
encrypt_key_operation()
|
||||
elif choice == "2":
|
||||
change_password_operation()
|
||||
elif choice == "3":
|
||||
console.print("[bold cyan]再见![/bold cyan]")
|
||||
break
|
||||
|
||||
|
||||
def encrypt_key_operation():
|
||||
"""加密密钥文件的交互操作"""
|
||||
console.clear()
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold cyan]加密 PEM 密钥文件[/bold cyan]",
|
||||
title="[bold blue]加密操作[/bold blue]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
# 获取密钥文件路径
|
||||
default_path = "data/keys/private_key.pem"
|
||||
private_key_path = Prompt.ask(
|
||||
"[bold]请输入 RSA 私钥文件路径[/bold]",
|
||||
default=default_path,
|
||||
console=console,
|
||||
)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold cyan]正在操作密钥文件[/bold cyan]\n"
|
||||
f"[cyan]文件路径:{private_key_path}[/cyan]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
pem_path = Path(private_key_path)
|
||||
if not pem_path.exists():
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold red]✗ 文件不存在: {private_key_path}[/bold red]",
|
||||
title="[bold red]错误[/bold red]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
Prompt.ask(
|
||||
"\n[bold]按 Enter 返回主菜单[/bold]", console=console, show_default=False
|
||||
)
|
||||
return
|
||||
|
||||
# 验证是否是有效的 RSA 私钥
|
||||
try:
|
||||
with open(pem_path, "rb") as f:
|
||||
serialization.load_pem_private_key(
|
||||
f.read(), password=None, backend=default_backend()
|
||||
)
|
||||
console.print("[bold green]✓ RSA 私钥验证成功[/bold green]")
|
||||
except Exception as e:
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold red]✗ 无效的 RSA 私钥文件: {str(e)}[/bold red]",
|
||||
title="[bold red]错误[/bold red]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
Prompt.ask(
|
||||
"\n[bold]按 Enter 返回主菜单[/bold]", console=console, show_default=False
|
||||
)
|
||||
return
|
||||
|
||||
# 设置密码
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold cyan]请为该密钥文件设置密码[/bold cyan]",
|
||||
title="[bold blue]设置密码[/bold blue]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
password = Prompt.ask("[bold]请输入密码[/bold]", password=True, console=console)
|
||||
password_confirm = Prompt.ask(
|
||||
"[bold]请确认密码[/bold]", password=True, console=console
|
||||
)
|
||||
|
||||
if password != password_confirm:
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold red]✗ 两次输入的密码不一致[/bold red]",
|
||||
title="[bold red]错误[/bold red]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
Prompt.ask(
|
||||
"\n[bold]按 Enter 返回主菜单[/bold]", console=console, show_default=False
|
||||
)
|
||||
return
|
||||
|
||||
# 加密文件
|
||||
console.print("[bold cyan]正在加密文件...[/bold cyan]")
|
||||
hex_path = encrypt_pem_file(private_key_path, password)
|
||||
|
||||
if not hex_path:
|
||||
Prompt.ask(
|
||||
"\n[bold]按 Enter 返回主菜单[/bold]", console=console, show_default=False
|
||||
)
|
||||
return
|
||||
|
||||
# 备份原文件
|
||||
backup_path = str(pem_path) + ".backup"
|
||||
shutil.copy(pem_path, backup_path)
|
||||
|
||||
# 删除原文件
|
||||
pem_path.unlink()
|
||||
|
||||
# 如果存在公钥文件,也转换为 .hex
|
||||
public_key_path = str(pem_path).replace("private_key.pem", "public_key.pem")
|
||||
if Path(public_key_path).exists():
|
||||
public_hex_path = public_key_path.replace(".pem", ".hex")
|
||||
shutil.copy(public_key_path, public_hex_path)
|
||||
Path(public_key_path).unlink()
|
||||
console.print(f"[cyan]公钥文件已转换: {public_hex_path}[/cyan]")
|
||||
|
||||
# 显示结果
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold green]✓ 密钥文件加密成功[/bold green]",
|
||||
title="[bold blue]完成[/bold blue]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
table = Table(title="加密结果")
|
||||
table.add_column("项目", style="cyan")
|
||||
table.add_column("路径", style="green")
|
||||
|
||||
table.add_row("原文件备份", backup_path)
|
||||
table.add_row("加密后的文件", hex_path)
|
||||
|
||||
console.print(table)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold yellow]提示:原 .pem 文件已删除,请妥善保管上述路径中的文件[/bold yellow]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
Prompt.ask(
|
||||
"\n[bold]按 Enter 返回主菜单[/bold]", console=console, show_default=False
|
||||
)
|
||||
|
||||
|
||||
def change_password_operation():
|
||||
"""修改密码的交互操作"""
|
||||
console.clear()
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold cyan]修改密钥密码[/bold cyan]",
|
||||
title="[bold blue]密码修改[/bold blue]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
# 扫描所有 .hex 文件
|
||||
console.print("[bold cyan]扫描密钥文件中...[/bold cyan]")
|
||||
_, hex_files = find_all_key_files()
|
||||
|
||||
if not hex_files:
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold yellow]未找到任何 .hex 密钥文件[/bold yellow]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
Prompt.ask(
|
||||
"\n[bold]按 Enter 返回主菜单[/bold]", console=console, show_default=False
|
||||
)
|
||||
return
|
||||
|
||||
# 显示所有可用的 .hex 文件
|
||||
console.print()
|
||||
console.print("[bold cyan]可用的密钥文件:[/bold cyan]")
|
||||
table = Table()
|
||||
table.add_column("序号", style="yellow")
|
||||
table.add_column("文件路径", style="green")
|
||||
table.add_column("大小", style="cyan")
|
||||
|
||||
for idx, file_path in enumerate(hex_files, 1):
|
||||
file_size = file_path.stat().st_size
|
||||
table.add_row(str(idx), str(file_path), f"{file_size} bytes")
|
||||
|
||||
console.print(table)
|
||||
|
||||
# 让用户选择要修改的文件
|
||||
console.print()
|
||||
valid_choices = [str(i) for i in range(1, len(hex_files) + 1)]
|
||||
choice = Prompt.ask(
|
||||
"[bold]请选择要修改的密钥文件序号[/bold]",
|
||||
choices=valid_choices,
|
||||
console=console,
|
||||
)
|
||||
|
||||
selected_hex_file = hex_files[int(choice) - 1]
|
||||
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold cyan]正在操作密钥文件[/bold cyan]\n"
|
||||
f"[cyan]文件路径:{selected_hex_file}[/cyan]",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
change_key_password(str(selected_hex_file))
|
||||
Prompt.ask(
|
||||
"\n[bold]按 Enter 返回主菜单[/bold]", console=console, show_default=False
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
main_menu()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
114
loveace/config/logger.py
Normal file
114
loveace/config/logger.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from loveace.config.manager import config_manager
|
||||
from loveace.utils.richuru_hook import install
|
||||
|
||||
|
||||
def setup_logger():
|
||||
"""根据配置文件设置loguru日志"""
|
||||
|
||||
settings = config_manager.get_settings()
|
||||
log_config = settings.log
|
||||
|
||||
# 移除默认的logger配置
|
||||
logger.remove()
|
||||
# 安装 richuru 并配置更详细的堆栈跟踪信息
|
||||
install()
|
||||
# 确保日志目录存在
|
||||
log_dir = Path(log_config.file_path).parent
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 设置主日志文件 - 带有详细路径信息
|
||||
logger.add(
|
||||
log_config.file_path,
|
||||
level=log_config.level.value,
|
||||
rotation=log_config.rotation,
|
||||
retention=log_config.retention,
|
||||
compression=log_config.compression,
|
||||
backtrace=log_config.backtrace,
|
||||
diagnose=log_config.diagnose,
|
||||
# 自定义格式,显示完整的文件路径和行号
|
||||
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} | {message}",
|
||||
)
|
||||
logger.info("日志系统初始化完成")
|
||||
|
||||
|
||||
def get_logger():
|
||||
"""获取配置好的logger实例"""
|
||||
return logger
|
||||
|
||||
|
||||
class LoggerMixin:
|
||||
"""用户日志混合类"""
|
||||
|
||||
user_id: str = ""
|
||||
trace_id: str = ""
|
||||
|
||||
def __init__(self, user_id: str = "", trace_id: str = ""):
|
||||
self.user_id = user_id
|
||||
self.trace_id = trace_id
|
||||
|
||||
def _build_message(self, message: str):
|
||||
if self.user_id and self.trace_id:
|
||||
return f"[{self.user_id}] [{self.trace_id}] {message}"
|
||||
|
||||
elif self.user_id:
|
||||
return f"[{self.user_id}] {message}"
|
||||
|
||||
elif self.trace_id:
|
||||
return f"[{self.trace_id}] {message}"
|
||||
|
||||
else:
|
||||
return message
|
||||
|
||||
def _build_alt_message(self, alt: str):
|
||||
if self.user_id and self.trace_id:
|
||||
return f"[bold green][{self.user_id}][/bold green] [bold blue][{self.trace_id}][/bold blue] {alt}"
|
||||
elif self.user_id:
|
||||
return f"[bold green][{self.user_id}][/bold green] {alt}"
|
||||
elif self.trace_id:
|
||||
return f"[bold blue][{self.trace_id}][/bold blue] {alt}"
|
||||
else:
|
||||
return alt
|
||||
|
||||
def info(self, message: str, alt: str = ""):
|
||||
logger.opt(depth=1).info(
|
||||
self._build_message(message),
|
||||
alt=self._build_alt_message(alt if alt else message),
|
||||
)
|
||||
|
||||
def debug(self, message: str, alt: str = ""):
|
||||
logger.opt(depth=1).debug(
|
||||
self._build_message(message),
|
||||
alt=self._build_alt_message(alt if alt else message),
|
||||
)
|
||||
|
||||
def warning(self, message: str, alt: str = ""):
|
||||
logger.opt(depth=1).warning(
|
||||
self._build_message(message),
|
||||
alt=self._build_alt_message(alt if alt else message),
|
||||
)
|
||||
|
||||
def error(self, message: str, alt: str = ""):
|
||||
logger.opt(depth=1).error(
|
||||
self._build_message(message),
|
||||
alt=self._build_alt_message(alt if alt else message),
|
||||
)
|
||||
|
||||
def success(self, message: str, alt: str = ""):
|
||||
logger.opt(depth=1).success(
|
||||
self._build_message(message),
|
||||
alt=self._build_alt_message(alt if alt else message),
|
||||
)
|
||||
|
||||
def exception(self, e: Exception):
|
||||
logger.opt(depth=1).exception(e)
|
||||
|
||||
|
||||
def get_user_logger(user_id: str):
|
||||
return LoggerMixin(user_id)
|
||||
|
||||
|
||||
setup_logger()
|
||||
@@ -1,40 +1,41 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .models import Settings
|
||||
from loveace.config.settings import Settings
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""配置文件管理器"""
|
||||
|
||||
|
||||
def __init__(self, config_file: str = "config.json"):
|
||||
self.config_file = Path(config_file)
|
||||
self._settings: Optional[Settings] = None
|
||||
self._ensure_config_dir()
|
||||
|
||||
|
||||
def _ensure_config_dir(self):
|
||||
"""确保配置文件目录存在"""
|
||||
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _create_default_config(self) -> Settings:
|
||||
"""创建默认配置"""
|
||||
logger.info("正在创建默认配置文件...")
|
||||
return Settings()
|
||||
|
||||
|
||||
def _save_config(self, settings: Settings):
|
||||
"""保存配置到文件"""
|
||||
try:
|
||||
config_dict = settings.dict()
|
||||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||
with open(self.config_file, "w", encoding="utf-8") as f:
|
||||
json.dump(config_dict, f, indent=2, ensure_ascii=False)
|
||||
logger.info(f"配置已保存到 {self.config_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"保存配置文件失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def _load_config(self) -> Settings:
|
||||
"""从文件加载配置"""
|
||||
if not self.config_file.exists():
|
||||
@@ -42,16 +43,16 @@ class ConfigManager:
|
||||
settings = self._create_default_config()
|
||||
self._save_config(settings)
|
||||
return settings
|
||||
|
||||
|
||||
try:
|
||||
with open(self.config_file, 'r', encoding='utf-8') as f:
|
||||
with open(self.config_file, "r", encoding="utf-8") as f:
|
||||
config_data = json.load(f)
|
||||
|
||||
|
||||
# 验证并创建Settings对象
|
||||
settings = Settings(**config_data)
|
||||
logger.info(f"成功加载配置文件: {self.config_file}")
|
||||
return settings
|
||||
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"配置文件JSON格式错误: {e}")
|
||||
raise
|
||||
@@ -61,31 +62,31 @@ class ConfigManager:
|
||||
except Exception as e:
|
||||
logger.error(f"加载配置文件失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def get_settings(self) -> Settings:
|
||||
"""获取配置设置"""
|
||||
if self._settings is None:
|
||||
self._settings = self._load_config()
|
||||
return self._settings
|
||||
|
||||
|
||||
def reload_config(self) -> Settings:
|
||||
"""重新加载配置"""
|
||||
logger.info("正在重新加载配置...")
|
||||
self._settings = self._load_config()
|
||||
return self._settings
|
||||
|
||||
|
||||
def update_config(self, **kwargs) -> Settings:
|
||||
"""更新配置"""
|
||||
settings = self.get_settings()
|
||||
|
||||
|
||||
# 创建新的配置字典
|
||||
config_dict = settings.dict()
|
||||
|
||||
|
||||
# 更新指定的配置项
|
||||
for key, value in kwargs.items():
|
||||
if '.' in key:
|
||||
if "." in key:
|
||||
# 支持嵌套键,如 'database.url'
|
||||
keys = key.split('.')
|
||||
keys = key.split(".")
|
||||
current = config_dict
|
||||
for k in keys[:-1]:
|
||||
if k not in current:
|
||||
@@ -94,7 +95,7 @@ class ConfigManager:
|
||||
current[keys[-1]] = value
|
||||
else:
|
||||
config_dict[key] = value
|
||||
|
||||
|
||||
try:
|
||||
# 验证更新后的配置
|
||||
new_settings = Settings(**config_dict)
|
||||
@@ -105,25 +106,25 @@ class ConfigManager:
|
||||
except ValidationError as e:
|
||||
logger.error(f"配置更新失败,验证错误: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
"""验证配置完整性"""
|
||||
try:
|
||||
settings = self.get_settings()
|
||||
|
||||
|
||||
# 检查关键配置项
|
||||
issues = []
|
||||
|
||||
|
||||
# 检查数据库配置
|
||||
if not settings.database.url:
|
||||
issues.append("数据库URL未配置")
|
||||
|
||||
|
||||
# 检查S3配置(如果需要使用)
|
||||
if settings.s3.bucket_name and not settings.s3.access_key_id:
|
||||
issues.append("S3配置不完整:缺少access_key_id")
|
||||
if settings.s3.bucket_name and not settings.s3.secret_access_key:
|
||||
issues.append("S3配置不完整:缺少secret_access_key")
|
||||
|
||||
|
||||
# 检查日志配置
|
||||
log_dir = Path(settings.log.file_path).parent
|
||||
if not log_dir.exists():
|
||||
@@ -132,32 +133,28 @@ class ConfigManager:
|
||||
logger.info(f"创建日志目录: {log_dir}")
|
||||
except Exception as e:
|
||||
issues.append(f"无法创建日志目录 {log_dir}: {e}")
|
||||
|
||||
|
||||
if issues:
|
||||
logger.warning("配置验证发现问题:")
|
||||
for issue in issues:
|
||||
logger.warning(f" - {issue}")
|
||||
return False
|
||||
|
||||
|
||||
logger.info("配置验证通过")
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"配置验证失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_config_summary(self) -> Dict[str, Any]:
|
||||
"""获取配置摘要(隐藏敏感信息)"""
|
||||
settings = self.get_settings()
|
||||
config_dict = settings.dict()
|
||||
|
||||
|
||||
# 隐藏敏感信息
|
||||
sensitive_keys = [
|
||||
'database.url',
|
||||
's3.access_key_id',
|
||||
's3.secret_access_key'
|
||||
]
|
||||
|
||||
sensitive_keys = ["database.url", "s3.access_key_id", "s3.secret_access_key"]
|
||||
|
||||
def hide_sensitive(data: Dict[str, Any], keys: list, prefix: str = ""):
|
||||
for key, value in data.items():
|
||||
current_key = f"{prefix}.{key}" if prefix else key
|
||||
@@ -166,11 +163,11 @@ class ConfigManager:
|
||||
data[key] = value[:8] + "..." if len(value) > 8 else "***"
|
||||
elif isinstance(value, dict):
|
||||
hide_sensitive(value, keys, current_key)
|
||||
|
||||
|
||||
summary = config_dict.copy()
|
||||
hide_sensitive(summary, sensitive_keys)
|
||||
return summary
|
||||
|
||||
|
||||
# 全局配置管理器实例
|
||||
config_manager = ConfigManager()
|
||||
config_manager = ConfigManager()
|
||||
@@ -1,10 +1,12 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class LogLevel(str, Enum):
|
||||
"""日志级别枚举"""
|
||||
|
||||
TRACE = "TRACE"
|
||||
DEBUG = "DEBUG"
|
||||
INFO = "INFO"
|
||||
@@ -16,9 +18,10 @@ class LogLevel(str, Enum):
|
||||
|
||||
class DatabaseConfig(BaseModel):
|
||||
"""数据库配置"""
|
||||
|
||||
url: str = Field(
|
||||
default="mysql+aiomysql://root:123456@localhost:3306/loveac",
|
||||
description="数据库连接URL"
|
||||
description="数据库连接URL",
|
||||
)
|
||||
echo: bool = Field(default=False, description="是否启用SQL日志")
|
||||
pool_size: int = Field(default=10, description="连接池大小")
|
||||
@@ -29,16 +32,24 @@ class DatabaseConfig(BaseModel):
|
||||
|
||||
class ISIMConfig(BaseModel):
|
||||
"""ISIM后勤电费系统配置"""
|
||||
|
||||
base_url: str = Field(
|
||||
default="http://hqkd-aufe-edu-cn.vpn2.aufe.edu.cn/",
|
||||
description="ISIM系统基础URL"
|
||||
default="http://hqkd-aufe-edu-cn.vpn2.aufe.edu.cn",
|
||||
description="ISIM系统基础URL",
|
||||
)
|
||||
room_cache_path: str = Field(
|
||||
default="data/isim_rooms.json", description="寝室信息缓存路径"
|
||||
)
|
||||
room_cache_expire: int = Field(
|
||||
default=86400, description="寝室信息刷新间隔(秒)"
|
||||
) # 默认24小时刷新一次
|
||||
session_timeout: int = Field(default=1800, description="会话超时时间(秒)")
|
||||
retry_times: int = Field(default=3, description="请求重试次数")
|
||||
|
||||
|
||||
class AUFEConfig(BaseModel):
|
||||
"""AUFE连接配置"""
|
||||
|
||||
default_timeout: int = Field(default=30, description="默认超时时间(秒)")
|
||||
max_retries: int = Field(default=3, description="最大重试次数")
|
||||
max_reconnect_retries: int = Field(default=2, description="最大重连次数")
|
||||
@@ -47,37 +58,70 @@ class AUFEConfig(BaseModel):
|
||||
retry_base_delay: float = Field(default=1.0, description="重试基础延迟(秒)")
|
||||
retry_max_delay: float = Field(default=60.0, description="重试最大延迟(秒)")
|
||||
retry_exponential_base: float = Field(default=2, description="重试指数基数")
|
||||
|
||||
server_url: str = Field(
|
||||
default="https://vpn.aufe.edu.cn", description="AUFE服务器URL"
|
||||
)
|
||||
ec_check_url: str = Field(
|
||||
default="http://txzx-aufe-edu-cn-s.vpn2.aufe.edu.cn:8118/dzzy/list.htm",
|
||||
description="EC检查URL",
|
||||
)
|
||||
|
||||
# UAAP配置
|
||||
uaap_base_url: str = Field(
|
||||
default="http://uaap-aufe-edu-cn.vpn2.aufe.edu.cn:8118/cas",
|
||||
description="UAAP基础URL"
|
||||
description="UAAP基础URL",
|
||||
)
|
||||
uaap_login_url: str = Field(
|
||||
default="http://uaap-aufe-edu-cn.vpn2.aufe.edu.cn:8118/cas/login?service=http%3A%2F%2Fjwcxk2.aufe.edu.cn%2Fj_spring_cas_security_check",
|
||||
description="UAAP登录URL"
|
||||
description="UAAP登录URL",
|
||||
)
|
||||
|
||||
uaap_check_url: str = Field(
|
||||
default="http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/",
|
||||
description="UAAP检查链接",
|
||||
)
|
||||
|
||||
# 默认请求头
|
||||
default_headers: Dict[str, str] = Field(
|
||||
default_factory=lambda: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||
},
|
||||
description="默认请求头"
|
||||
description="默认请求头",
|
||||
)
|
||||
|
||||
|
||||
class RedisConfig(BaseModel):
|
||||
"""Redis客户端配置"""
|
||||
|
||||
host: str = Field(default="localhost", description="Redis主机地址")
|
||||
port: int = Field(default=6379, description="Redis端口")
|
||||
db: int = Field(default=0, description="Redis数据库编号")
|
||||
password: Optional[str] = Field(default=None, description="Redis密码")
|
||||
encoding: str = Field(default="utf-8", description="字符编码")
|
||||
decode_responses: bool = Field(default=True, description="是否自动解码响应")
|
||||
max_connections: int = Field(default=50, description="连接池最大连接数")
|
||||
socket_keepalive: bool = Field(default=True, description="是否启用socket保活")
|
||||
socket_keepalive_options: Optional[Dict[str, Any]] = Field(
|
||||
default=None, description="Socket保活选项"
|
||||
)
|
||||
health_check_interval: int = Field(default=30, description="健康检查间隔(秒)")
|
||||
retry_on_timeout: bool = Field(default=True, description="超时时是否重试")
|
||||
|
||||
|
||||
class S3Config(BaseModel):
|
||||
"""S3客户端配置"""
|
||||
|
||||
access_key_id: str = Field(default="", description="S3访问密钥ID")
|
||||
secret_access_key: str = Field(default="", description="S3秘密访问密钥")
|
||||
endpoint_url: Optional[str] = Field(default=None, description="S3终端节点URL")
|
||||
endpoint_url: str = Field(default="", description="S3终端节点URL")
|
||||
region_name: str = Field(default="us-east-1", description="S3区域名称")
|
||||
bucket_name: str = Field(default="", description="默认存储桶名称")
|
||||
use_ssl: bool = Field(default=True, description="是否使用SSL")
|
||||
signature_version: str = Field(default="s3v4", description="签名版本")
|
||||
|
||||
@field_validator('access_key_id', 'secret_access_key', 'bucket_name')
|
||||
addressing_style: str = Field(
|
||||
default="auto", description="地址风格(auto, path, virtual)"
|
||||
)
|
||||
|
||||
@field_validator("access_key_id", "secret_access_key", "bucket_name")
|
||||
@classmethod
|
||||
def validate_required_fields(cls, v):
|
||||
"""验证必填字段"""
|
||||
@@ -87,11 +131,8 @@ class S3Config(BaseModel):
|
||||
|
||||
class LogConfig(BaseModel):
|
||||
"""日志配置"""
|
||||
|
||||
level: LogLevel = Field(default=LogLevel.INFO, description="日志级别")
|
||||
format: str = Field(
|
||||
default="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
||||
description="日志格式"
|
||||
)
|
||||
file_path: str = Field(default="logs/app.log", description="日志文件路径")
|
||||
rotation: str = Field(default="10 MB", description="日志轮转大小")
|
||||
retention: str = Field(default="30 days", description="日志保留时间")
|
||||
@@ -99,64 +140,55 @@ class LogConfig(BaseModel):
|
||||
backtrace: bool = Field(default=True, description="是否启用回溯")
|
||||
diagnose: bool = Field(default=True, description="是否启用诊断")
|
||||
console_output: bool = Field(default=True, description="是否输出到控制台")
|
||||
|
||||
# 额外的日志文件配置
|
||||
additional_loggers: List[Dict[str, Any]] = Field(
|
||||
default_factory=lambda: [
|
||||
{
|
||||
"file_path": "logs/debug.log",
|
||||
"level": "DEBUG",
|
||||
"rotation": "10 MB"
|
||||
},
|
||||
{
|
||||
"file_path": "logs/error.log",
|
||||
"level": "ERROR",
|
||||
"rotation": "10 MB"
|
||||
}
|
||||
],
|
||||
description="额外的日志记录器配置"
|
||||
)
|
||||
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
"""应用程序配置"""
|
||||
title: str = Field(default="LoveAC API", description="应用标题")
|
||||
description: str = Field(default="LoveACAPI API", description="应用描述")
|
||||
|
||||
title: str = Field(default="LoveACE API", description="应用标题")
|
||||
description: str = Field(default="LoveACE API", description="应用描述")
|
||||
version: str = Field(default="1.0.0", description="应用版本")
|
||||
debug: bool = Field(default=False, description="是否启用调试模式")
|
||||
|
||||
|
||||
# CORS配置
|
||||
cors_allow_origins: List[str] = Field(
|
||||
default_factory=lambda: ["*"],
|
||||
description="允许的CORS来源"
|
||||
default_factory=lambda: ["*"], description="允许的CORS来源"
|
||||
)
|
||||
cors_allow_credentials: bool = Field(default=True, description="是否允许CORS凭据")
|
||||
cors_allow_methods: List[str] = Field(
|
||||
default_factory=lambda: ["*"],
|
||||
description="允许的CORS方法"
|
||||
default_factory=lambda: ["*"], description="允许的CORS方法"
|
||||
)
|
||||
cors_allow_headers: List[str] = Field(
|
||||
default_factory=lambda: ["*"],
|
||||
description="允许的CORS头部"
|
||||
default_factory=lambda: ["*"], description="允许的CORS头部"
|
||||
)
|
||||
|
||||
|
||||
# 服务器配置
|
||||
host: str = Field(default="0.0.0.0", description="服务器主机")
|
||||
port: int = Field(default=8000, description="服务器端口")
|
||||
workers: int = Field(default=1, description="工作进程数")
|
||||
|
||||
# 安全配置
|
||||
rsa_private_key_path: str = Field(
|
||||
default="private_key.hex", description="RSA私钥路径"
|
||||
)
|
||||
rsa_protect_key_path: str = Field(
|
||||
default="data/keys/", description="RSA保护密钥存储路径"
|
||||
)
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
"""主配置类"""
|
||||
|
||||
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
|
||||
redis: RedisConfig = Field(default_factory=RedisConfig)
|
||||
aufe: AUFEConfig = Field(default_factory=AUFEConfig)
|
||||
isim: ISIMConfig = Field(default_factory=ISIMConfig)
|
||||
s3: S3Config = Field(default_factory=S3Config)
|
||||
log: LogConfig = Field(default_factory=LogConfig)
|
||||
app: AppConfig = Field(default_factory=AppConfig)
|
||||
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
# 为枚举类型提供JSON编码器
|
||||
LogLevel: lambda v: v.value
|
||||
}
|
||||
}
|
||||
14
loveace/database/aac/ticket.py
Normal file
14
loveace/database/aac/ticket.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from loveace.database.base import Base
|
||||
|
||||
|
||||
class AACTicket(Base):
|
||||
__tablename__ = "aac_ticket_table"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
userid: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
aac_token: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
|
||||
13
loveace/database/auth/login.py
Normal file
13
loveace/database/auth/login.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from loveace.database.base import Base
|
||||
|
||||
|
||||
class LoginCoolDown(Base):
|
||||
__tablename__ = "login_cooldown_table"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
userid: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||
expire_date: Mapped[datetime.datetime] = mapped_column(nullable=False)
|
||||
20
loveace/database/auth/register.py
Normal file
20
loveace/database/auth/register.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from loveace.database.base import Base
|
||||
|
||||
|
||||
class InviteCode(Base):
|
||||
__tablename__ = "invite_code_table"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
code: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
|
||||
|
||||
|
||||
class RegisterCoolDown(Base):
|
||||
__tablename__ = "register_cooldown_table"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
userid: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||
expire_date: Mapped[datetime.datetime] = mapped_column(nullable=False)
|
||||
15
loveace/database/auth/token.py
Normal file
15
loveace/database/auth/token.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from loveace.database.base import Base
|
||||
|
||||
|
||||
class AuthMEToken(Base):
|
||||
__tablename__ = "auth_me_token_table"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
token: Mapped[str] = mapped_column(String(256), unique=True, nullable=False)
|
||||
device_id: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
|
||||
16
loveace/database/auth/user.py
Normal file
16
loveace/database/auth/user.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from loveace.database.base import Base
|
||||
|
||||
|
||||
class ACEUser(Base):
|
||||
__tablename__ = "ace_user_table"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
userid: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||
password: Mapped[str] = mapped_column(String(2048), nullable=True)
|
||||
ec_password: Mapped[str] = mapped_column(String(2048), nullable=True)
|
||||
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
|
||||
last_login_date: Mapped[datetime.datetime] = mapped_column(nullable=True)
|
||||
149
loveace/database/creator.py
Normal file
149
loveace/database/creator.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from loveace.config.logger import logger
|
||||
from loveace.config.manager import config_manager
|
||||
from loveace.database.base import Base
|
||||
|
||||
|
||||
class DatabaseManager:
|
||||
"""数据库管理器,负责数据库连接和会话管理"""
|
||||
|
||||
def __init__(self):
|
||||
self.engine = None
|
||||
self.async_session_maker = None
|
||||
self._config = None
|
||||
self.redis_client = None
|
||||
self._redis_config = None
|
||||
|
||||
def _get_db_config(self):
|
||||
"""获取数据库配置"""
|
||||
if self._config is None:
|
||||
self._config = config_manager.get_settings().database
|
||||
return self._config
|
||||
|
||||
def _get_redis_config(self):
|
||||
"""获取Redis配置"""
|
||||
if self._redis_config is None:
|
||||
self._redis_config = config_manager.get_settings().redis
|
||||
return self._redis_config
|
||||
|
||||
async def init_db(self) -> bool:
|
||||
"""初始化数据库连接"""
|
||||
db_config = self._get_db_config()
|
||||
|
||||
logger.info("正在初始化数据库连接...")
|
||||
try:
|
||||
self.engine = create_async_engine(
|
||||
db_config.url,
|
||||
echo=db_config.echo,
|
||||
pool_size=db_config.pool_size,
|
||||
max_overflow=db_config.max_overflow,
|
||||
pool_timeout=db_config.pool_timeout,
|
||||
pool_recycle=db_config.pool_recycle,
|
||||
future=True,
|
||||
)
|
||||
|
||||
self.async_session_maker = async_sessionmaker(
|
||||
self.engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
# 创建所有表
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
except Exception as e:
|
||||
logger.error(f"数据库连接初始化失败: {e}")
|
||||
logger.error(f"数据库连接URL: {db_config.url}")
|
||||
db_config.url = "****"
|
||||
logger.error(f"数据库连接配置: {db_config}")
|
||||
logger.error("请启动config_tui.py来配置数据库连接")
|
||||
return False
|
||||
logger.info("数据库连接初始化完成")
|
||||
return True
|
||||
|
||||
async def close_db(self):
|
||||
"""关闭数据库连接"""
|
||||
if self.engine:
|
||||
logger.info("正在关闭数据库连接...")
|
||||
await self.engine.dispose()
|
||||
logger.info("数据库连接已关闭")
|
||||
|
||||
async def get_redis_client(self) -> aioredis.Redis:
|
||||
"""获取Redis客户端
|
||||
|
||||
Returns:
|
||||
Redis客户端实例
|
||||
|
||||
Raises:
|
||||
RuntimeError: 如果Redis初始化失败
|
||||
"""
|
||||
if self.redis_client is None:
|
||||
success = await self._init_redis()
|
||||
if not success:
|
||||
raise RuntimeError(
|
||||
"Failed to initialize Redis client. Check logs for details."
|
||||
)
|
||||
return self.redis_client # type: ignore
|
||||
|
||||
async def _init_redis(self) -> bool:
|
||||
"""初始化Redis连接"""
|
||||
redis_config = self._get_redis_config()
|
||||
|
||||
logger.info("正在初始化Redis连接...")
|
||||
try:
|
||||
self.redis_client = aioredis.Redis(
|
||||
host=redis_config.host,
|
||||
port=redis_config.port,
|
||||
db=redis_config.db,
|
||||
password=redis_config.password,
|
||||
encoding=redis_config.encoding,
|
||||
decode_responses=redis_config.decode_responses,
|
||||
max_connections=redis_config.max_connections,
|
||||
socket_keepalive=redis_config.socket_keepalive,
|
||||
)
|
||||
# 测试连接
|
||||
await self.redis_client.ping()
|
||||
logger.info("Redis连接初始化完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Redis连接初始化失败: {e}")
|
||||
logger.error(
|
||||
f"Redis配置: host={redis_config.host}, port={redis_config.port}, db={redis_config.db}"
|
||||
)
|
||||
return False
|
||||
|
||||
async def close_redis(self):
|
||||
"""关闭Redis连接"""
|
||||
if self.redis_client:
|
||||
logger.info("正在关闭Redis连接...")
|
||||
await self.redis_client.close()
|
||||
self.redis_client = None
|
||||
logger.info("Redis连接已关闭")
|
||||
|
||||
async def get_session(self) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""获取数据库会话"""
|
||||
if not self.async_session_maker:
|
||||
raise RuntimeError("Database not initialized. Call init_db() first.")
|
||||
|
||||
async with self.async_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
# 全局数据库管理器实例
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""获取数据库会话的依赖函数,用于FastAPI路由"""
|
||||
async for session in db_manager.get_session():
|
||||
yield session
|
||||
|
||||
|
||||
async def get_redis_instance() -> aioredis.Redis:
|
||||
"""获取Redis实例的依赖函数,用于FastAPI路由"""
|
||||
return await db_manager.get_redis_client()
|
||||
15
loveace/database/isim/room.py
Normal file
15
loveace/database/isim/room.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from loveace.database.base import Base
|
||||
|
||||
|
||||
class RoomBind(Base):
|
||||
__tablename__ = "isim_room_bind_table"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
roomid: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
roomtext: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
|
||||
1
loveace/database/ldjlb/__init__.py
Normal file
1
loveace/database/ldjlb/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 劳动俱乐部数据库模型
|
||||
14
loveace/database/ldjlb/ticket.py
Normal file
14
loveace/database/ldjlb/ticket.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from loveace.database.base import Base
|
||||
|
||||
|
||||
class LDJLBTicket(Base):
|
||||
__tablename__ = "ldjlb_ticket_table"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
userid: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
ldjlb_token: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
|
||||
26
loveace/database/profile/flutter_profile.py
Normal file
26
loveace/database/profile/flutter_profile.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from loveace.database.base import Base
|
||||
|
||||
|
||||
class FlutterThemeProfile(Base):
|
||||
__tablename__ = "flutter_theme_profile"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(nullable=False, unique=True)
|
||||
dark_mode: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
light_mode_opacity: Mapped[float] = mapped_column(nullable=False, default=1.0)
|
||||
light_mode_brightness: Mapped[float] = mapped_column(nullable=False, default=1.0)
|
||||
light_mode_background_url: Mapped[str] = mapped_column(String(300), nullable=True)
|
||||
light_mode_background_md5: Mapped[str] = mapped_column(String(128), nullable=True)
|
||||
light_mode_blur: Mapped[float] = mapped_column(nullable=False, default=0.0)
|
||||
dark_mode_opacity: Mapped[float] = mapped_column(nullable=False, default=1.0)
|
||||
dark_mode_brightness: Mapped[float] = mapped_column(nullable=False, default=1.0)
|
||||
dark_mode_background_url: Mapped[str] = mapped_column(String(300), nullable=True)
|
||||
dark_mode_background_md5: Mapped[str] = mapped_column(String(128), nullable=True)
|
||||
dark_mode_background_blur: Mapped[float] = mapped_column(
|
||||
nullable=False, default=0.0
|
||||
)
|
||||
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
|
||||
17
loveace/database/profile/user_profile.py
Normal file
17
loveace/database/profile/user_profile.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from loveace.database.base import Base
|
||||
|
||||
|
||||
class UserProfile(Base):
|
||||
__tablename__ = "ace_user_profile"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
nickname: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
slogan: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||
avatar_url: Mapped[str] = mapped_column(String(200), nullable=True)
|
||||
avatar_md5: Mapped[str] = mapped_column(String(128), nullable=True)
|
||||
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
|
||||
24
loveace/middleware/process_time.py
Normal file
24
loveace/middleware/process_time.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import time
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
|
||||
from loveace.config.logger import logger
|
||||
|
||||
|
||||
class ProcessTimeMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
start_time = time.time()
|
||||
logger.info(
|
||||
f"{request.method} {request.url.path} START",
|
||||
f"[Bold White][{request.method}][/Bold White] {request.url.path} [Bold Green]START[/Bold Green]",
|
||||
)
|
||||
response: Response = await call_next(request)
|
||||
process_time = time.time() - start_time
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
logger.info(
|
||||
f"{request.method} {request.url.path} END ({process_time:.4f}s)",
|
||||
f"[Bold White][{request.method}][/Bold White] {request.url.path} [Bold Green]END[/Bold Green] [Dim]({process_time:.4f}s)[/Dim]",
|
||||
)
|
||||
return response
|
||||
13
loveace/router/dependencies/__init__.py
Normal file
13
loveace/router/dependencies/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Router dependencies"""
|
||||
|
||||
from loveace.router.dependencies.auth import get_user_by_token
|
||||
from loveace.router.dependencies.logger import (
|
||||
logger_mixin_with_user,
|
||||
no_user_logger_mixin,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"no_user_logger_mixin",
|
||||
"logger_mixin_with_user",
|
||||
"get_user_by_token",
|
||||
]
|
||||
55
loveace/router/dependencies/auth.py
Normal file
55
loveace/router/dependencies/auth.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from loveace.database.auth.token import AuthMEToken
|
||||
from loveace.database.auth.user import ACEUser
|
||||
from loveace.database.creator import get_db_session
|
||||
from loveace.router.dependencies.logger import LoggerMixin, no_user_logger_mixin
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.exception import UniResponseHTTPException
|
||||
|
||||
auth_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_user_by_token(
|
||||
authorization: Annotated[
|
||||
HTTPAuthorizationCredentials | None, Depends(auth_scheme)
|
||||
] = None,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
logger: LoggerMixin = Depends(no_user_logger_mixin),
|
||||
) -> ACEUser:
|
||||
"""通过Token获取用户"""
|
||||
if not authorization:
|
||||
logger.error("缺少认证令牌")
|
||||
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
|
||||
logger.trace_id
|
||||
)
|
||||
token = authorization.credentials
|
||||
try:
|
||||
async with db_session as session:
|
||||
query = select(AuthMEToken).where(AuthMEToken.token == token)
|
||||
result = await session.execute(query)
|
||||
user_token = result.scalars().first()
|
||||
if user_token is None:
|
||||
logger.error("无效的认证令牌")
|
||||
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
|
||||
logger.trace_id
|
||||
)
|
||||
query = select(ACEUser).where(ACEUser.userid == user_token.user_id)
|
||||
result = await session.execute(query)
|
||||
user = result.scalars().first()
|
||||
if user is None:
|
||||
logger.error("用户不存在")
|
||||
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
|
||||
logger.trace_id
|
||||
)
|
||||
return user
|
||||
except (HTTPException, UniResponseHTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise ProtectRouterErrorToCode().server_error.to_http_exception(logger.trace_id)
|
||||
11
loveace/router/dependencies/logger.py
Normal file
11
loveace/router/dependencies/logger.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import uuid
|
||||
|
||||
from loveace.config.logger import LoggerMixin
|
||||
|
||||
|
||||
def no_user_logger_mixin() -> LoggerMixin:
|
||||
return LoggerMixin(trace_id=str(uuid.uuid4().hex))
|
||||
|
||||
|
||||
def logger_mixin_with_user(userid: str) -> LoggerMixin:
|
||||
return LoggerMixin(trace_id=str(uuid.uuid4().hex), user_id=userid)
|
||||
10
loveace/router/endpoint/aac/__init__.py
Normal file
10
loveace/router/endpoint/aac/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from loveace.router.endpoint.aac.credit import aac_credit_router
|
||||
|
||||
aac_base_router = APIRouter(
|
||||
prefix="/aac",
|
||||
tags=["爱安财"],
|
||||
)
|
||||
|
||||
aac_base_router.include_router(aac_credit_router)
|
||||
185
loveace/router/endpoint/aac/credit.py
Normal file
185
loveace/router/endpoint/aac/credit.py
Normal file
@@ -0,0 +1,185 @@
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from httpx import Headers, HTTPError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from loveace.router.endpoint.aac.model.base import AACConfig
|
||||
from loveace.router.endpoint.aac.model.credit import (
|
||||
LoveACCreditCategory,
|
||||
LoveACCreditInfo,
|
||||
)
|
||||
from loveace.router.endpoint.aac.utils.aac_ticket import get_aac_header
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
aac_credit_router = APIRouter(
|
||||
prefix="/credit",
|
||||
responses=ProtectRouterErrorToCode().gen_code_table(),
|
||||
)
|
||||
|
||||
ENDPOINT = {
|
||||
"total_score": "/User/Center/DoGetScoreInfo?sf_request_type=ajax",
|
||||
"score_list": "/User/Center/DoGetScoreList?sf_request_type=ajax",
|
||||
}
|
||||
|
||||
|
||||
@aac_credit_router.get(
|
||||
"/info",
|
||||
response_model=UniResponseModel[LoveACCreditInfo],
|
||||
summary="获取爱安财总分信息",
|
||||
)
|
||||
async def get_credit_info(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
headers: Headers = Depends(get_aac_header),
|
||||
) -> UniResponseModel[LoveACCreditInfo] | JSONResponse:
|
||||
"""
|
||||
获取用户的爱安财总分信息
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取爱安财总分和毕业要求状态
|
||||
- 获取未达标的原因说明
|
||||
- 实时从 AUFE 服务获取最新数据
|
||||
|
||||
💡 使用场景:
|
||||
- 个人中心显示爱安财总分
|
||||
- 检查是否满足毕业要求
|
||||
- 了解分数不足的原因
|
||||
|
||||
Returns:
|
||||
LoveACCreditInfo: 包含总分、达成状态和详细信息
|
||||
"""
|
||||
try:
|
||||
conn.logger.info("开始获取爱安财总分信息")
|
||||
response = await conn.client.post(
|
||||
url=AACConfig().to_full_url(ENDPOINT["total_score"]),
|
||||
data={},
|
||||
headers=headers,
|
||||
timeout=6000,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取爱安财总分信息失败,HTTP状态码: {response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财总分信息失败,请稍后重试"
|
||||
)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
conn.logger.error(f"获取爱安财总分信息失败,响应代码: {data.get('code')}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财总分信息失败,请稍后重试"
|
||||
)
|
||||
data = data.get("data", {})
|
||||
if not data:
|
||||
conn.logger.error("获取爱安财总分信息失败,响应数据为空")
|
||||
return ProtectRouterErrorToCode().null_response.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财总分信息失败,请稍后重试"
|
||||
)
|
||||
try:
|
||||
credit_info = LoveACCreditInfo.model_validate(data)
|
||||
conn.logger.info("成功获取爱安财总分信息")
|
||||
return UniResponseModel[LoveACCreditInfo](
|
||||
success=True,
|
||||
data=credit_info,
|
||||
message="获取爱安财总分信息成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"解析爱安财总分信息失败: {str(ve)}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id, "解析爱安财总分信息失败,请稍后重试"
|
||||
)
|
||||
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"获取爱安财总分信息异常: {str(he)}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财总分信息异常,请稍后重试"
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取爱安财总分信息未知异常: {str(e)}")
|
||||
return ProtectRouterErrorToCode().unknown_error.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财总分信息未知异常,请稍后重试"
|
||||
)
|
||||
|
||||
|
||||
@aac_credit_router.get(
|
||||
"/list",
|
||||
response_model=UniResponseModel[List[LoveACCreditCategory]],
|
||||
summary="获取爱安财分数明细",
|
||||
)
|
||||
async def get_credit_list(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
headers: Headers = Depends(get_aac_header),
|
||||
) -> UniResponseModel[List[LoveACCreditCategory]] | JSONResponse:
|
||||
"""
|
||||
获取用户的爱安财分数明细列表
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取分数的详细分类信息
|
||||
- 显示每个分数项的具体内容
|
||||
- 支持分页查询
|
||||
|
||||
💡 使用场景:
|
||||
- 查看分数明细页面
|
||||
- 了解各类别分数构成
|
||||
- 分析分数不足的原因
|
||||
|
||||
Returns:
|
||||
list[LoveACCreditCategory]: 分数分类列表,每个分类包含多个分数项
|
||||
"""
|
||||
try:
|
||||
conn.logger.info("开始获取爱安财分数明细")
|
||||
response = await conn.client.post(
|
||||
url=AACConfig().to_full_url(ENDPOINT["score_list"]),
|
||||
data={"pageIndex": "1", "pageSize": "10"},
|
||||
headers=headers,
|
||||
timeout=6000,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取爱安财分数明细失败,HTTP状态码: {response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财分数明细失败,请稍后重试"
|
||||
)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
conn.logger.error(f"获取爱安财分数明细失败,响应代码: {data.get('code')}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财分数明细失败,请稍后重试"
|
||||
)
|
||||
data = data.get("data", [])
|
||||
if not data:
|
||||
conn.logger.error("获取爱安财分数明细失败,响应数据为空")
|
||||
return ProtectRouterErrorToCode().null_response.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财分数明细失败,请稍后重试"
|
||||
)
|
||||
try:
|
||||
credit_list = [LoveACCreditCategory.model_validate(item) for item in data]
|
||||
conn.logger.info("成功获取爱安财分数明细")
|
||||
return UniResponseModel[List[LoveACCreditCategory]](
|
||||
success=True,
|
||||
data=credit_list,
|
||||
message="获取爱安财分数明细成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"解析爱安财分数明细失败: {str(ve)}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id, "解析爱安财分数明细失败,请稍后重试"
|
||||
)
|
||||
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"获取爱安财分数明细异常: {str(he)}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财分数明细异常,请稍后重试"
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取爱安财分数明细未知异常: {str(e)}")
|
||||
return ProtectRouterErrorToCode().unknown_error.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财分数明细未知异常,请稍后重试"
|
||||
)
|
||||
22
loveace/router/endpoint/aac/model/base.py
Normal file
22
loveace/router/endpoint/aac/model/base.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
|
||||
from loveace.config.manager import config_manager
|
||||
|
||||
settings = config_manager.get_settings()
|
||||
|
||||
|
||||
class AACConfig:
|
||||
"""AAC 模块配置常量"""
|
||||
|
||||
BASE_URL = "http://api-dekt-ac-acxk-net.vpn2.aufe.edu.cn:8118"
|
||||
WEB_URL = "http://dekt-ac-acxk-net.vpn2.aufe.edu.cn:8118"
|
||||
LOGIN_SERVICE_URL = "http://uaap-aufe-edu-cn.vpn2.aufe.edu.cn:8118/cas/login?service=http%3a%2f%2fapi.dekt.ac.acxk.net%2fUser%2fIndex%2fCoreLoginCallback%3fisCASGateway%3dtrue"
|
||||
RSA_PRIVATE_KEY_PATH = str(
|
||||
Path(settings.app.rsa_protect_key_path).joinpath("aac_private_key.pem")
|
||||
)
|
||||
|
||||
def to_full_url(self, path: str) -> str:
|
||||
"""将路径转换为完整URL"""
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
return path
|
||||
return self.BASE_URL.rstrip("/") + "/" + path.lstrip("/")
|
||||
40
loveace/router/endpoint/aac/model/credit.py
Normal file
40
loveace/router/endpoint/aac/model/credit.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LoveACCreditInfo(BaseModel):
|
||||
"""爱安财总分信息"""
|
||||
|
||||
total_score: float = Field(
|
||||
0.0, alias="TotalScore", description="总分,爱安财服务端已四舍五入"
|
||||
)
|
||||
is_type_adopt: bool = Field(
|
||||
False, alias="IsTypeAdopt", description="是否达到毕业要求"
|
||||
)
|
||||
type_adopt_result: str = Field(
|
||||
"", alias="TypeAdoptResult", description="未达到毕业要求的原因"
|
||||
)
|
||||
|
||||
|
||||
class LoveACCreditItem(BaseModel):
|
||||
"""爱安财分数明细条目"""
|
||||
|
||||
id: str = Field("", alias="ID", description="条目ID")
|
||||
title: str = Field("", alias="Title", description="条目标题")
|
||||
type_name: str = Field("", alias="TypeName", description="条目类别名称")
|
||||
user_no: str = Field("", alias="UserNo", description="用户编号,即学号")
|
||||
score: float = Field(0.0, alias="Score", description="分数")
|
||||
add_time: str = Field("", alias="AddTime", description="添加时间")
|
||||
|
||||
|
||||
class LoveACCreditCategory(BaseModel):
|
||||
"""爱安财分数类别"""
|
||||
|
||||
id: str = Field("", alias="ID", description="类别ID")
|
||||
show_num: int = Field(0, alias="ShowNum", description="显示序号")
|
||||
type_name: str = Field("", alias="TypeName", description="类别名称")
|
||||
total_score: float = Field(0.0, alias="TotalScore", description="类别总分")
|
||||
children: List[LoveACCreditItem] = Field(
|
||||
[], alias="children", description="该类别下的分数明细列表"
|
||||
)
|
||||
167
loveace/router/endpoint/aac/utils/aac_ticket.py
Normal file
167
loveace/router/endpoint/aac/utils/aac_ticket.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from urllib.parse import unquote
|
||||
|
||||
from fastapi import Depends
|
||||
from httpx import Headers
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from loveace.config.manager import config_manager
|
||||
from loveace.database.aac.ticket import AACTicket
|
||||
from loveace.database.creator import get_db_session
|
||||
from loveace.router.dependencies.auth import ProtectRouterErrorToCode
|
||||
from loveace.router.endpoint.aac.model.base import AACConfig
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
from loveace.utils.rsa import RSAUtils
|
||||
|
||||
rsa = RSAUtils.get_or_create_rsa_utils(AACConfig.RSA_PRIVATE_KEY_PATH)
|
||||
|
||||
|
||||
def _extract_and_encrypt_token(location: str, logger) -> str | None:
|
||||
"""从重定向URL中提取并加密系统令牌"""
|
||||
try:
|
||||
sys_token = location.split("ticket=")[-1]
|
||||
# URL编码转为正常字符串
|
||||
sys_token = unquote(sys_token)
|
||||
if not sys_token:
|
||||
logger.error("系统令牌为空")
|
||||
return None
|
||||
|
||||
logger.info(f"获取到系统令牌: {sys_token[:10]}...")
|
||||
# 加密系统令牌
|
||||
encrypted_token = rsa.encrypt(sys_token)
|
||||
return encrypted_token
|
||||
except Exception as e:
|
||||
logger.error(f"解析/加密系统令牌失败: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_system_token(conn: AUFEConnection) -> str:
|
||||
next_location = AACConfig.LOGIN_SERVICE_URL
|
||||
max_redirects = 10 # 防止无限重定向
|
||||
redirect_count = 0
|
||||
try:
|
||||
while redirect_count < max_redirects:
|
||||
response = await conn.client.get(
|
||||
next_location, follow_redirects=False, timeout=conn.timeout
|
||||
)
|
||||
|
||||
# 如果是重定向,继续跟踪
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
next_location = response.headers.get("Location")
|
||||
if not next_location:
|
||||
conn.logger.error("重定向响应中缺少 Location 头")
|
||||
return ""
|
||||
|
||||
conn.logger.debug(f"重定向到: {next_location}")
|
||||
redirect_count += 1
|
||||
|
||||
if "register?ticket=" in next_location:
|
||||
conn.logger.info(f"重定向到爱安财注册页面: {next_location}")
|
||||
encrypted_token = _extract_and_encrypt_token(
|
||||
next_location, conn.logger
|
||||
)
|
||||
return encrypted_token if encrypted_token else ""
|
||||
else:
|
||||
break
|
||||
|
||||
if redirect_count >= max_redirects:
|
||||
conn.logger.error(f"重定向次数过多 ({max_redirects})")
|
||||
return ""
|
||||
|
||||
conn.logger.error("未能获取系统令牌")
|
||||
return ""
|
||||
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取系统令牌异常: {str(e)}")
|
||||
return ""
|
||||
|
||||
|
||||
async def get_aac_header(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Headers:
|
||||
"""
|
||||
获取AAC Ticket的依赖项。
|
||||
如果用户没有登录AUFE或UAAP,或者AAC Ticket不存在且无法获取新的Ticket,则会抛出HTTP异常。
|
||||
否则,返回有效的AAC Ticket字符串。
|
||||
"""
|
||||
# 检查AAC Ticket是否存在
|
||||
async with db as session:
|
||||
result = await session.execute(
|
||||
select(AACTicket).where(AACTicket.userid == conn.userid)
|
||||
)
|
||||
aac_ticket = result.scalars().first()
|
||||
|
||||
if not aac_ticket:
|
||||
aac_ticket = await _get_or_fetch_ticket(conn, db, is_new=True)
|
||||
else:
|
||||
aac_ticket_token = aac_ticket.aac_token
|
||||
try:
|
||||
# 解密以验证Ticket有效性
|
||||
decrypted_ticket = rsa.decrypt(aac_ticket_token)
|
||||
if not decrypted_ticket:
|
||||
raise ValueError("解密后的Ticket为空")
|
||||
aac_ticket = decrypted_ticket
|
||||
except Exception as e:
|
||||
conn.logger.error(
|
||||
f"用户 {conn.userid} 的 AAC Ticket 无效,正在获取新的 Ticket: {str(e)}"
|
||||
)
|
||||
aac_ticket = await _get_or_fetch_ticket(conn, db, is_new=False)
|
||||
else:
|
||||
conn.logger.info(f"用户 {conn.userid} 使用现有的 AAC Ticket")
|
||||
|
||||
return Headers(
|
||||
{
|
||||
**config_manager.get_settings().aufe.default_headers,
|
||||
"ticket": aac_ticket,
|
||||
"sdp-app-session": conn.twf_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _get_or_fetch_ticket(
|
||||
conn: AUFEConnection, db: AsyncSession, is_new: bool
|
||||
) -> str:
|
||||
"""获取或重新获取AAC Ticket并保存到数据库(返回解密后的ticket)"""
|
||||
action_type = "获取" if is_new else "重新获取"
|
||||
conn.logger.info(
|
||||
f"用户 {conn.userid} 的 AAC Ticket {'不存在' if is_new else '无效'},正在{action_type}新的 Ticket"
|
||||
)
|
||||
|
||||
encrypted_token = await get_system_token(conn)
|
||||
if not encrypted_token:
|
||||
conn.logger.error(f"用户 {conn.userid} {action_type} AAC Ticket 失败")
|
||||
raise ProtectRouterErrorToCode().remote_service_error.to_http_exception(
|
||||
conn.logger.trace_id,
|
||||
message="获取 AAC Ticket 失败,请检查 AUFE/UAAP 登录状态",
|
||||
)
|
||||
|
||||
# 解密token
|
||||
try:
|
||||
decrypted_token = rsa.decrypt(encrypted_token)
|
||||
if not decrypted_token:
|
||||
raise ValueError("解密后的Ticket为空")
|
||||
except Exception as e:
|
||||
conn.logger.error(f"用户 {conn.userid} 解密 AAC Ticket 失败: {str(e)}")
|
||||
raise ProtectRouterErrorToCode().remote_service_error.to_http_exception(
|
||||
conn.logger.trace_id,
|
||||
message="解密 AAC Ticket 失败",
|
||||
)
|
||||
|
||||
# 保存加密后的token到数据库
|
||||
async with db as session:
|
||||
if is_new:
|
||||
session.add(AACTicket(userid=conn.userid, aac_token=encrypted_token))
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(AACTicket).where(AACTicket.userid == conn.userid)
|
||||
)
|
||||
existing_ticket = result.scalars().first()
|
||||
if existing_ticket:
|
||||
existing_ticket.aac_token = encrypted_token
|
||||
await session.commit()
|
||||
|
||||
conn.logger.success(f"用户 {conn.userid} 成功{action_type}并保存新的 AAC Ticket")
|
||||
# 返回解密后的token
|
||||
return decrypted_token
|
||||
30
loveace/router/endpoint/apifox.py
Normal file
30
loveace/router/endpoint/apifox.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
apifox_router = APIRouter()
|
||||
|
||||
|
||||
@apifox_router.get(
|
||||
"/",
|
||||
tags=["首页"],
|
||||
summary="首页 - 请求后跳转到 Apifox 文档页面",
|
||||
response_model=None,
|
||||
responses={"307": {"description": "重定向到 Apifox 文档页面"}},
|
||||
)
|
||||
async def redirect_to_apifox():
|
||||
"""
|
||||
重定向到 API 文档页面
|
||||
|
||||
✅ 功能特性:
|
||||
- 自动重定向到 Apifox 文档
|
||||
- 提供 API 接口的完整文档
|
||||
- 包含参数说明和示例
|
||||
|
||||
💡 使用场景:
|
||||
- 访问 API 根路径时自动跳转
|
||||
- 获取 API 文档
|
||||
|
||||
Returns:
|
||||
RedirectResponse: 重定向到 Apifox 文档页面
|
||||
"""
|
||||
return RedirectResponse(url="https://docs.loveace.linota.cn/")
|
||||
10
loveace/router/endpoint/auth/__init__.py
Normal file
10
loveace/router/endpoint/auth/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from loveace.router.endpoint.auth.authme import authme_router
|
||||
from loveace.router.endpoint.auth.login import login_router
|
||||
from loveace.router.endpoint.auth.register import register_router
|
||||
|
||||
auth_router = APIRouter(prefix="/auth", tags=["用户验证"])
|
||||
auth_router.include_router(login_router)
|
||||
auth_router.include_router(register_router)
|
||||
auth_router.include_router(authme_router)
|
||||
45
loveace/router/endpoint/auth/authme.py
Normal file
45
loveace/router/endpoint/auth/authme.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from loveace.router.endpoint.auth.model.authme import AuthMeResponse
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
authme_router = APIRouter(
|
||||
prefix="/authme", responses=ProtectRouterErrorToCode.gen_code_table()
|
||||
)
|
||||
|
||||
|
||||
@authme_router.get(
|
||||
"/token",
|
||||
response_model=UniResponseModel[AuthMeResponse],
|
||||
summary="Token 有效性验证",
|
||||
)
|
||||
async def auth_me(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[AuthMeResponse] | JSONResponse:
|
||||
"""
|
||||
验证 Token 有效性并获取用户信息
|
||||
|
||||
✅ 功能特性:
|
||||
- 验证 Authme Token 是否有效
|
||||
- 返回当前认证用户的 ID
|
||||
- 用于前端权限验证
|
||||
|
||||
💡 使用场景:
|
||||
- 前端页面加载时验证登录状态
|
||||
- Token 过期检测
|
||||
- 获取当前登录用户信息
|
||||
|
||||
Returns:
|
||||
AuthMeResponse: 包含验证结果和用户 ID
|
||||
"""
|
||||
user_id = conn.userid
|
||||
return UniResponseModel[AuthMeResponse](
|
||||
success=True,
|
||||
data=AuthMeResponse(success=True, userid=user_id),
|
||||
message="Token 验证成功",
|
||||
error=None,
|
||||
)
|
||||
222
loveace/router/endpoint/auth/login.py
Normal file
222
loveace/router/endpoint/auth/login.py
Normal file
@@ -0,0 +1,222 @@
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from loveace.config.logger import LoggerMixin
|
||||
from loveace.database.auth.login import LoginCoolDown
|
||||
from loveace.database.auth.token import AuthMEToken
|
||||
from loveace.database.auth.user import ACEUser
|
||||
from loveace.database.creator import get_db_session
|
||||
from loveace.router.dependencies.logger import no_user_logger_mixin
|
||||
from loveace.router.endpoint.auth.model.login import (
|
||||
LoginErrorToCode,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
)
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEService
|
||||
from loveace.service.remote.aufe.depends import get_aufe_service
|
||||
from loveace.utils.rsa import RSAUtils
|
||||
|
||||
login_router = APIRouter(prefix="/login", responses=LoginErrorToCode.gen_code_table())
|
||||
rsa_util = RSAUtils.get_or_create_rsa_utils()
|
||||
|
||||
|
||||
@login_router.post(
|
||||
"/next",
|
||||
response_model=UniResponseModel[LoginResponse],
|
||||
summary="用户登录",
|
||||
)
|
||||
async def login(
|
||||
login_request: LoginRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
aufe_service: AUFEService = Depends(get_aufe_service),
|
||||
logger: LoggerMixin = Depends(no_user_logger_mixin),
|
||||
) -> UniResponseModel[LoginResponse] | JSONResponse:
|
||||
"""
|
||||
用户登录,返回 Authme Token
|
||||
|
||||
✅ 功能特性:
|
||||
- 通过 AUFE 服务验证 EC 密码和登录密码
|
||||
- 限制用户总 Token 数为 5 个
|
||||
- 登录失败后设置 1 分钟冷却时间
|
||||
|
||||
⚠️ 限制条件:
|
||||
- 连续登录失败会触发冷却机制
|
||||
- 冷却期间内拒绝该用户的登录请求
|
||||
|
||||
💡 使用场景:
|
||||
- 用户首次登录
|
||||
- 用户重新登录(更换设备)
|
||||
- 用户忘记密码后重新设置并登录
|
||||
|
||||
Args:
|
||||
login_request: 包含用户 ID、EC 密码、登录密码的登录请求
|
||||
db: 数据库会话
|
||||
aufe_service: AUFE 远程认证服务
|
||||
logger: 日志记录器
|
||||
|
||||
Returns:
|
||||
LoginResponse: 包含新生成的 Authme Token
|
||||
"""
|
||||
try:
|
||||
async with db as session:
|
||||
logger.info(f"用户登录: {login_request.userid}")
|
||||
# 检查用户是否存在
|
||||
query = select(ACEUser).where(ACEUser.userid == login_request.userid)
|
||||
result = await session.execute(query)
|
||||
user = result.scalars().first()
|
||||
if user is None:
|
||||
logger.info(f"用户不存在: {login_request.userid}")
|
||||
return LoginErrorToCode().invalid_credentials.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
# 检查是否在冷却时间内
|
||||
query = select(LoginCoolDown).where(LoginCoolDown.userid == user.userid)
|
||||
result = await session.execute(query)
|
||||
cooldown = result.scalars().first()
|
||||
if cooldown and cooldown.expire_date > datetime.now():
|
||||
logger.info(f"用户 {login_request.userid} 在冷却时间内,拒绝登录")
|
||||
return LoginErrorToCode().cooldown.to_json_response(logger.trace_id)
|
||||
# 解密数据库中的 EC密码 登录密码 和 请求体中的 EC密码 登录密码
|
||||
try:
|
||||
db_ec_password = rsa_util.decrypt(user.ec_password)
|
||||
db_password = rsa_util.decrypt(user.password)
|
||||
ec_password = rsa_util.decrypt(login_request.ec_password)
|
||||
password = rsa_util.decrypt(login_request.password)
|
||||
except Exception as e:
|
||||
logger.info(f"用户 {login_request.userid} 提供的密码解密失败: {e}")
|
||||
return LoginErrorToCode().invalid_credentials.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
# 尝试使用AUFE服务验证EC密码和登录密码
|
||||
conn = await aufe_service.get_or_create_connection(
|
||||
userid=login_request.userid,
|
||||
ec_password=ec_password,
|
||||
password=password,
|
||||
)
|
||||
if not await conn.health_check():
|
||||
logger.info(f"用户 {login_request.userid} 的AUFE连接不可用")
|
||||
|
||||
# EC密码登录重试机制 (最多3次)
|
||||
ec_login_status = None
|
||||
for ec_retry in range(3):
|
||||
ec_login_status = await conn.ec_login()
|
||||
if ec_login_status.success:
|
||||
break
|
||||
|
||||
# 如果是攻击防范或密码错误,直接退出重试
|
||||
if (
|
||||
ec_login_status.fail_maybe_attacked
|
||||
or ec_login_status.fail_invalid_credentials
|
||||
):
|
||||
logger.info(
|
||||
f"用户 {login_request.userid} EC登录失败 (攻击防范或密码错误),停止重试"
|
||||
)
|
||||
break
|
||||
|
||||
logger.info(
|
||||
f"用户 {login_request.userid} EC登录重试第 {ec_retry + 1} 次"
|
||||
)
|
||||
|
||||
if not ec_login_status or not ec_login_status.success:
|
||||
logger.info(f"用户 {login_request.userid} 的EC密码错误")
|
||||
# 设置冷却时间
|
||||
cooldown_time = timedelta(minutes=1)
|
||||
if cooldown:
|
||||
cooldown.expire_date = datetime.now() + cooldown_time
|
||||
else:
|
||||
cooldown = LoginCoolDown(
|
||||
userid=user.userid,
|
||||
expire_date=datetime.now() + cooldown_time,
|
||||
)
|
||||
session.add(cooldown)
|
||||
await session.commit()
|
||||
return (
|
||||
LoginErrorToCode().remote_invalid_credentials.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
)
|
||||
|
||||
# UAAP密码登录重试机制 (最多3次)
|
||||
uaap_login_status = None
|
||||
for uaap_retry in range(3):
|
||||
uaap_login_status = await conn.uaap_login()
|
||||
if uaap_login_status.success:
|
||||
break
|
||||
|
||||
# 如果是密码错误,直接退出重试
|
||||
if uaap_login_status.fail_invalid_credentials:
|
||||
logger.info(
|
||||
f"用户 {login_request.userid} UAAP登录失败 (密码错误),停止重试"
|
||||
)
|
||||
break
|
||||
|
||||
logger.info(
|
||||
f"用户 {login_request.userid} UAAP登录重试第 {uaap_retry + 1} 次"
|
||||
)
|
||||
|
||||
if not uaap_login_status or not uaap_login_status.success:
|
||||
logger.info(f"用户 {login_request.userid} 的登录密码错误")
|
||||
# 设置冷却时间
|
||||
cooldown_time = timedelta(minutes=1)
|
||||
if cooldown:
|
||||
cooldown.expire_date = datetime.now() + cooldown_time
|
||||
else:
|
||||
cooldown = LoginCoolDown(
|
||||
userid=user.userid,
|
||||
expire_date=datetime.now() + cooldown_time,
|
||||
)
|
||||
session.add(cooldown)
|
||||
await session.commit()
|
||||
return (
|
||||
LoginErrorToCode().remote_invalid_credentials.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
)
|
||||
# 删除冷却时间
|
||||
if cooldown:
|
||||
await session.delete(cooldown)
|
||||
await session.commit()
|
||||
# 比对密码,如果新的密码与数据库中的密码不一致,则更新数据库中的密码
|
||||
if db_ec_password != ec_password or db_password != password:
|
||||
user.ec_password = rsa_util.encrypt(ec_password)
|
||||
user.password = rsa_util.encrypt(password)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
logger.info(f"用户 {login_request.userid} 的密码已更新")
|
||||
# 创建新的Authme Token
|
||||
new_token = AuthMEToken(
|
||||
user_id=user.userid,
|
||||
token=secrets.token_urlsafe(32),
|
||||
device_id=uuid4().hex,
|
||||
)
|
||||
session.add(new_token)
|
||||
await session.commit()
|
||||
# 限制用户总 Token 数为5个,删除最早的 Token
|
||||
query = (
|
||||
select(AuthMEToken)
|
||||
.where(AuthMEToken.user_id == user.userid)
|
||||
.order_by(AuthMEToken.create_date.asc())
|
||||
)
|
||||
result = await session.execute(query)
|
||||
tokens = result.scalars().all()
|
||||
if len(tokens) > 5:
|
||||
for token in tokens[:-5]:
|
||||
await session.delete(token)
|
||||
await session.commit()
|
||||
logger.info(f"用户 {login_request.userid} 登录成功,返回Token")
|
||||
return UniResponseModel[LoginResponse](
|
||||
success=True,
|
||||
data=LoginResponse(token=new_token.token),
|
||||
message="登录成功",
|
||||
error=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"用户 {login_request.userid} 登录时发生错误: {e}")
|
||||
return LoginErrorToCode().server_error.to_json_response(logger.trace_id)
|
||||
6
loveace/router/endpoint/auth/model/authme.py
Normal file
6
loveace/router/endpoint/auth/model/authme.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AuthMeResponse(BaseModel):
|
||||
success: bool = Field(..., description="是否验证成功")
|
||||
userid: str = Field(..., description="用户ID")
|
||||
37
loveace/router/endpoint/auth/model/login.py
Normal file
37
loveace/router/endpoint/auth/model/login.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from fastapi import status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from loveace.router.schemas.base import ErrorToCode, ErrorToCodeNode
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
userid: str = Field(..., description="用户ID")
|
||||
ec_password: str = Field(..., description="用户EC密码,rsa encrypt加密后的密文")
|
||||
password: str = Field(..., description="用户登录密码,rsa encrypt加密后的密文")
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
token: str = Field(..., description="用户登录成功后返回的Authme Token")
|
||||
|
||||
|
||||
class LoginErrorToCode(ErrorToCode):
|
||||
invalid_credentials: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_403_FORBIDDEN,
|
||||
code="CREDENTIALS_INVALID",
|
||||
message="凭证无效",
|
||||
)
|
||||
remote_invalid_credentials: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_403_FORBIDDEN,
|
||||
code="REMOTE_CREDENTIALS_INVALID",
|
||||
message="远程凭证无效,EC密码或登录密码错误,需要进行密码重置",
|
||||
)
|
||||
cooldown: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
code="COOLDOWN",
|
||||
message="操作过于频繁,请稍后再试",
|
||||
)
|
||||
server_error: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
code="SERVER_ERROR",
|
||||
message="服务器错误",
|
||||
)
|
||||
99
loveace/router/endpoint/auth/model/register.py
Normal file
99
loveace/router/endpoint/auth/model/register.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from fastapi import status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from loveace.router.schemas import (
|
||||
ErrorToCode,
|
||||
ErrorToCodeNode,
|
||||
)
|
||||
|
||||
##############################################################
|
||||
# * 用户注册相关模型-邀请码 *#
|
||||
|
||||
|
||||
class InviteCodeRequest(BaseModel):
|
||||
invite_code: str = Field(..., description="邀请码")
|
||||
|
||||
|
||||
class InviteCodeResponse(BaseModel):
|
||||
token: str = Field(..., description="邀请码验证成功后返回的Token")
|
||||
|
||||
|
||||
class InviteErrorToCode(ErrorToCode):
|
||||
invalid_invite_code: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_403_FORBIDDEN,
|
||||
code="INVITE_CODE_INVALID",
|
||||
message="邀请码错误",
|
||||
)
|
||||
server_error: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
code="SERVER_ERROR",
|
||||
message="服务器错误",
|
||||
)
|
||||
|
||||
|
||||
##############################################################
|
||||
|
||||
##############################################################
|
||||
# * 用户注册相关模型-注册 *#
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
userid: str = Field(..., description="用户ID")
|
||||
ec_password: str = Field(..., description="用户EC密码,rsa encrypt加密后的密文")
|
||||
password: str = Field(..., description="用户登录密码,rsa encrypt加密后的密文")
|
||||
token: str = Field(..., description="邀请码验证成功后返回的Token")
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
token: str = Field(..., description="用户登录成功后返回的Authme Token")
|
||||
|
||||
|
||||
class RegisterErrorToCode(ErrorToCode):
|
||||
invalid_token: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_403_FORBIDDEN,
|
||||
code="TOKEN_INVALID",
|
||||
message="Token无效",
|
||||
)
|
||||
userid_exists: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_409_CONFLICT,
|
||||
code="USERID_EXISTS",
|
||||
message="用户ID已存在",
|
||||
)
|
||||
decrypt_error: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="DECRYPT_ERROR",
|
||||
message="密码解密失败",
|
||||
)
|
||||
ec_server_error: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="EC_SERVER_ERROR",
|
||||
message="EC服务错误",
|
||||
)
|
||||
ec_password_error: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="EC_PASSWORD_ERROR",
|
||||
message="EC密码错误",
|
||||
)
|
||||
uaap_server_error: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="UAAP_SERVER_ERROR",
|
||||
message="UAAP服务错误",
|
||||
)
|
||||
uaap_password_error: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="UAAP_PASSWORD_ERROR",
|
||||
message="UAAP密码错误",
|
||||
)
|
||||
register_in_cooldown: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
code="REGISTER_IN_COOLDOWN",
|
||||
message="注册请求过于频繁,请稍后再试",
|
||||
)
|
||||
server_error: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
code="SERVER_ERROR",
|
||||
message="服务器错误",
|
||||
)
|
||||
|
||||
|
||||
##############################################################
|
||||
247
loveace/router/endpoint/auth/register.py
Normal file
247
loveace/router/endpoint/auth/register.py
Normal file
@@ -0,0 +1,247 @@
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from loveace.config.logger import LoggerMixin
|
||||
from loveace.database.auth.register import InviteCode as InviteCodeDB
|
||||
from loveace.database.auth.register import RegisterCoolDown
|
||||
from loveace.database.auth.token import AuthMEToken
|
||||
from loveace.database.auth.user import ACEUser
|
||||
from loveace.database.creator import get_db_session
|
||||
from loveace.router.dependencies.logger import no_user_logger_mixin
|
||||
from loveace.router.endpoint.auth.model.register import (
|
||||
InviteCodeRequest,
|
||||
InviteCodeResponse,
|
||||
InviteErrorToCode,
|
||||
RegisterErrorToCode,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
)
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEService
|
||||
from loveace.service.remote.aufe.depends import get_aufe_service
|
||||
from loveace.utils.rsa import RSAUtils
|
||||
|
||||
register_router = APIRouter(prefix="/register")
|
||||
|
||||
|
||||
temp_tokens = []
|
||||
|
||||
rsa_util = RSAUtils.get_or_create_rsa_utils()
|
||||
|
||||
|
||||
@register_router.post(
|
||||
"/invite",
|
||||
response_model=UniResponseModel[InviteCodeResponse],
|
||||
responses=InviteErrorToCode.gen_code_table(),
|
||||
summary="邀请码验证",
|
||||
)
|
||||
async def register(
|
||||
invite_code: InviteCodeRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
logger: LoggerMixin = Depends(no_user_logger_mixin),
|
||||
) -> UniResponseModel[InviteCodeResponse] | JSONResponse:
|
||||
"""
|
||||
验证邀请码并返回临时 Token
|
||||
|
||||
✅ 功能特性:
|
||||
- 验证邀请码的有效性
|
||||
- 生成临时 Token 用于后续注册步骤
|
||||
- 邀请码一次性使用
|
||||
|
||||
💡 使用场景:
|
||||
- 用户注册流程的第一步
|
||||
- 邀请制系统的验证
|
||||
|
||||
Args:
|
||||
invite_code: 邀请码请求对象
|
||||
db: 数据库会话
|
||||
logger: 日志记录器
|
||||
|
||||
Returns:
|
||||
InviteCodeResponse: 包含临时 Token
|
||||
"""
|
||||
try:
|
||||
async with db as session:
|
||||
logger.info(f"邀请码: {invite_code.invite_code}")
|
||||
invite = select(InviteCodeDB).where(
|
||||
InviteCodeDB.code == invite_code.invite_code
|
||||
)
|
||||
result = await session.execute(invite)
|
||||
invite_data = result.scalars().first()
|
||||
if invite_data is None:
|
||||
logger.info(f"邀请码不存在: {invite_code.invite_code}")
|
||||
return InviteErrorToCode().invalid_invite_code.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
token = secrets.token_urlsafe(128)
|
||||
temp_tokens.append(token)
|
||||
return UniResponseModel[InviteCodeResponse](
|
||||
success=True,
|
||||
data=InviteCodeResponse(token=token),
|
||||
message="邀请码验证成功",
|
||||
error=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("邀请码验证失败:")
|
||||
logger.exception(e)
|
||||
return InviteErrorToCode().server_error.to_json_response(logger.trace_id)
|
||||
|
||||
|
||||
@register_router.post(
|
||||
"/next",
|
||||
response_model=UniResponseModel[RegisterResponse],
|
||||
responses=RegisterErrorToCode.gen_code_table(),
|
||||
summary="用户注册",
|
||||
)
|
||||
async def register_user(
|
||||
register_info: RegisterRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
logger: LoggerMixin = Depends(no_user_logger_mixin),
|
||||
aufe_service: AUFEService = Depends(get_aufe_service),
|
||||
) -> UniResponseModel[RegisterResponse] | JSONResponse:
|
||||
"""
|
||||
用户注册,验证身份并创建账户
|
||||
|
||||
✅ 功能特性:
|
||||
- 通过 AUFE 服务验证 EC 密码和登录密码
|
||||
- 验证身份信息的有效性
|
||||
- 生成 Authme Token 用于登录
|
||||
|
||||
⚠️ 限制条件:
|
||||
- EC 密码或登录密码错误会触发 5 分钟冷却时间
|
||||
- 用户 ID 不能重复
|
||||
- 必须提供有效的邀请 Token
|
||||
|
||||
💡 使用场景:
|
||||
- 新用户注册
|
||||
- 创建学号对应的账户
|
||||
|
||||
Args:
|
||||
register_info: 包含用户 ID、EC 密码、登录密码和邀请 Token 的注册信息
|
||||
db: 数据库会话
|
||||
logger: 日志记录器
|
||||
aufe_service: AUFE 远程认证服务
|
||||
|
||||
Returns:
|
||||
RegisterResponse: 包含 Authme Token
|
||||
"""
|
||||
try:
|
||||
async with db as session:
|
||||
# COOLDOWN检查
|
||||
query = select(RegisterCoolDown).where(
|
||||
RegisterCoolDown.userid == register_info.userid
|
||||
)
|
||||
result = await session.execute(query)
|
||||
cooldown = result.scalars().first()
|
||||
if cooldown:
|
||||
if cooldown.expire_date > datetime.now():
|
||||
logger.info(f"用户ID注册冷却中: {register_info.userid}")
|
||||
return RegisterErrorToCode().userid_exists.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
else:
|
||||
await session.delete(cooldown)
|
||||
await session.commit()
|
||||
if register_info.token not in temp_tokens:
|
||||
logger.info(f"无效的注册Token: {register_info.token}")
|
||||
return RegisterErrorToCode().invalid_token.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
query = select(ACEUser).where(ACEUser.userid == register_info.userid)
|
||||
result = await session.execute(query)
|
||||
user = result.scalars().first()
|
||||
if user is not None:
|
||||
logger.info(f"用户ID已存在: {register_info.userid}")
|
||||
return RegisterErrorToCode().userid_exists.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
# 尝试使用AUFE服务验证EC密码
|
||||
try:
|
||||
ec_password = rsa_util.decrypt(register_info.ec_password)
|
||||
password = rsa_util.decrypt(register_info.password)
|
||||
except Exception as e:
|
||||
logger.info(f"用户 {register_info.userid} 提供的密码解密失败: {e}")
|
||||
return RegisterErrorToCode().decrypt_error.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
conn = await aufe_service.get_or_create_connection(
|
||||
userid=register_info.userid,
|
||||
ec_password=ec_password,
|
||||
password=password,
|
||||
)
|
||||
ec_login_status = await conn.ec_login()
|
||||
if not ec_login_status.success:
|
||||
cooldown_entry = RegisterCoolDown(
|
||||
userid=register_info.userid,
|
||||
expire_date=datetime.now() + timedelta(minutes=5),
|
||||
)
|
||||
session.add(cooldown_entry)
|
||||
await session.commit()
|
||||
if ec_login_status.fail_invalid_credentials:
|
||||
logger.info(f"EC密码错误: {register_info.userid}")
|
||||
return RegisterErrorToCode().ec_password_error.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
else:
|
||||
logger.error(f"AUFE服务异常: {ec_login_status}")
|
||||
return RegisterErrorToCode().ec_server_error.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
|
||||
uaap_login_status = await conn.uaap_login()
|
||||
if not uaap_login_status.success:
|
||||
cooldown_entry = RegisterCoolDown(
|
||||
userid=register_info.userid,
|
||||
expire_date=datetime.now() + timedelta(minutes=5),
|
||||
)
|
||||
session.add(cooldown_entry)
|
||||
await session.commit()
|
||||
if uaap_login_status.fail_invalid_credentials:
|
||||
logger.info(f"登录密码错误: {register_info.userid}")
|
||||
return RegisterErrorToCode().uaap_password_error.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
else:
|
||||
logger.error(f"AUFE服务异常: {uaap_login_status}")
|
||||
return RegisterErrorToCode().ec_server_error.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
# 创建新用户
|
||||
new_user = ACEUser(
|
||||
userid=register_info.userid,
|
||||
ec_password=register_info.ec_password,
|
||||
password=register_info.password,
|
||||
)
|
||||
session.add(new_user)
|
||||
await session.commit()
|
||||
# 注册成功后删除临时Token
|
||||
temp_tokens.remove(register_info.token)
|
||||
# 生成Authme Token
|
||||
authme_token = secrets.token_urlsafe(128)
|
||||
new_token = AuthMEToken(
|
||||
user_id=new_user.userid, token=authme_token, device_id=uuid4().hex
|
||||
)
|
||||
session.add(new_token)
|
||||
await session.commit()
|
||||
return UniResponseModel[RegisterResponse](
|
||||
success=True,
|
||||
data=RegisterResponse(token=authme_token),
|
||||
message="注册成功",
|
||||
error=None,
|
||||
)
|
||||
except ValueError as ve:
|
||||
logger.error("用户注册失败: RSA解密错误")
|
||||
logger.exception(ve)
|
||||
return RegisterErrorToCode().server_error.to_json_response(
|
||||
logger.trace_id, "RSA解密错误,请检查授权密文"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("用户注册失败:")
|
||||
logger.exception(e)
|
||||
return RegisterErrorToCode().server_error.to_json_response(logger.trace_id)
|
||||
12
loveace/router/endpoint/isim/__init__.py
Normal file
12
loveace/router/endpoint/isim/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from loveace.router.endpoint.isim.elec import isim_elec_router
|
||||
from loveace.router.endpoint.isim.room import isim_room_router
|
||||
|
||||
isim_base_router = APIRouter(
|
||||
prefix="/isim",
|
||||
tags=["电费"],
|
||||
)
|
||||
|
||||
isim_base_router.include_router(isim_room_router)
|
||||
isim_base_router.include_router(isim_elec_router)
|
||||
74
loveace/router/endpoint/isim/elec.py
Normal file
74
loveace/router/endpoint/isim/elec.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from loveace.database.isim.room import RoomBind
|
||||
from loveace.router.endpoint.isim.model.isim import (
|
||||
UniISIMInfoResponse,
|
||||
)
|
||||
from loveace.router.endpoint.isim.model.protect_router import ISIMRouterErrorToCode
|
||||
from loveace.router.endpoint.isim.utils.isim import ISIMClient, get_isim_client
|
||||
from loveace.router.endpoint.isim.utils.room import get_bound_room
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
|
||||
isim_elec_router = APIRouter(
|
||||
prefix="/elec",
|
||||
responses=ISIMRouterErrorToCode().gen_code_table(),
|
||||
)
|
||||
|
||||
|
||||
@isim_elec_router.get(
|
||||
"/info",
|
||||
summary="获取寝室电费信息",
|
||||
response_model=UniResponseModel[UniISIMInfoResponse],
|
||||
)
|
||||
async def get_isim_info(
|
||||
isim: ISIMClient = Depends(get_isim_client),
|
||||
room: RoomBind = Depends(get_bound_room),
|
||||
) -> UniResponseModel[UniISIMInfoResponse] | JSONResponse:
|
||||
"""
|
||||
获取用户绑定宿舍的电费信息
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取当前电费余额
|
||||
- 获取用电记录历史
|
||||
- 获取缴费记录
|
||||
|
||||
💡 使用场景:
|
||||
- 个人中心查看宿舍电费
|
||||
- 监测用电情况
|
||||
- 查看缴费历史
|
||||
|
||||
Returns:
|
||||
UniISIMInfoResponse: 包含房间信息、电费余额、用电记录、缴费记录
|
||||
"""
|
||||
try:
|
||||
# 使用 ISIMClient 的集成方法获取电费信息
|
||||
result = await isim.get_electricity_info(room.roomid)
|
||||
|
||||
if result is None:
|
||||
isim.client.logger.error(f"获取寝室 {room.roomid} 电费信息失败")
|
||||
return ISIMRouterErrorToCode().remote_service_error.to_json_response(
|
||||
isim.client.logger.trace_id
|
||||
)
|
||||
|
||||
room_display = await isim.get_room_display_text(room.roomid)
|
||||
room_display = "" if room_display is None else room_display
|
||||
return UniResponseModel[UniISIMInfoResponse](
|
||||
success=True,
|
||||
data=UniISIMInfoResponse(
|
||||
room_code=room.roomid,
|
||||
room_display=room_display,
|
||||
room_text=room.roomtext,
|
||||
balance=result["balance"],
|
||||
usage_records=result["usage_records"],
|
||||
payments=result["payments"],
|
||||
),
|
||||
message="获取寝室电费信息成功",
|
||||
error=None,
|
||||
)
|
||||
except Exception as e:
|
||||
isim.client.logger.error("获取寝室电费信息异常")
|
||||
isim.client.logger.exception(e)
|
||||
return ISIMRouterErrorToCode().server_error.to_json_response(
|
||||
isim.client.logger.trace_id
|
||||
)
|
||||
42
loveace/router/endpoint/isim/model/isim.py
Normal file
42
loveace/router/endpoint/isim/model/isim.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ==================== 电费相关模型 ====================
|
||||
|
||||
|
||||
class ElectricityBalance(BaseModel):
|
||||
"""电费余额信息"""
|
||||
|
||||
remaining_purchased: float = Field(..., description="剩余购电(度)")
|
||||
remaining_subsidy: float = Field(..., description="剩余补助(度)")
|
||||
|
||||
|
||||
class ElectricityUsageRecord(BaseModel):
|
||||
"""用电记录"""
|
||||
|
||||
record_time: str = Field(..., description="记录时间,如:2025-08-29 00:04:58")
|
||||
usage_amount: float = Field(..., description="用电量(度)")
|
||||
meter_name: str = Field(..., description="电表名称,如:1-101 或 1-101空调")
|
||||
|
||||
|
||||
# ==================== 充值相关模型 ====================
|
||||
|
||||
|
||||
class PaymentRecord(BaseModel):
|
||||
"""充值记录"""
|
||||
|
||||
payment_time: str = Field(..., description="充值时间,如:2025-02-21 11:30:08")
|
||||
amount: float = Field(..., description="充值金额(元)")
|
||||
payment_type: str = Field(..., description="充值类型,如:下发补助、一卡通充值")
|
||||
|
||||
|
||||
class UniISIMInfoResponse(BaseModel):
|
||||
"""寝室电费信息"""
|
||||
|
||||
room_code: str = Field(..., description="寝室代码")
|
||||
room_text: str = Field(..., description="寝室显示名称")
|
||||
room_display: str = Field(..., description="寝室显示名称")
|
||||
balance: ElectricityBalance = Field(..., description="电费余额")
|
||||
usage_records: List[ElectricityUsageRecord] = Field(..., description="用电记录")
|
||||
payments: List[PaymentRecord] = Field(..., description="充值记录")
|
||||
18
loveace/router/endpoint/isim/model/protect_router.py
Normal file
18
loveace/router/endpoint/isim/model/protect_router.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from fastapi import status
|
||||
|
||||
from loveace.router.schemas.error import ErrorToCodeNode, ProtectRouterErrorToCode
|
||||
|
||||
|
||||
class ISIMRouterErrorToCode(ProtectRouterErrorToCode):
|
||||
"""ISIM 统一错误码"""
|
||||
|
||||
UNBOUNDROOM: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="UNBOUND_ROOM",
|
||||
message="房间未绑定",
|
||||
)
|
||||
CACHEDROOMSEXPIRED: ErrorToCodeNode = ErrorToCodeNode(
|
||||
error_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="CACHED_ROOMS_EXPIRED",
|
||||
message="房间缓存已过期,请稍后重新获取房间列表",
|
||||
)
|
||||
172
loveace/router/endpoint/isim/model/room.py
Normal file
172
loveace/router/endpoint/isim/model/room.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
##############################################################
|
||||
# * 寝室绑定请求模型 *#
|
||||
##############################################################
|
||||
|
||||
|
||||
class BindRoomRequest(BaseModel):
|
||||
"""绑定寝室请求模型"""
|
||||
|
||||
room_id: str = Field(..., description="寝室ID")
|
||||
|
||||
|
||||
##############################################################
|
||||
# * 寝室绑定响应模型 *#
|
||||
##############################################################
|
||||
|
||||
|
||||
class BindRoomResponse(BaseModel):
|
||||
"""绑定寝室响应模型"""
|
||||
|
||||
success: bool = Field(..., description="是否绑定成功")
|
||||
|
||||
|
||||
##############################################################
|
||||
# * 楼栋信息模型 *#
|
||||
##############################################################
|
||||
|
||||
|
||||
class BuildingInfo(BaseModel):
|
||||
"""楼栋信息"""
|
||||
|
||||
code: str = Field(..., description="楼栋代码")
|
||||
name: str = Field(..., description="楼栋名称")
|
||||
|
||||
|
||||
##############################################################
|
||||
# * 楼层信息模型 *#
|
||||
##############################################################
|
||||
|
||||
|
||||
class FloorInfo(BaseModel):
|
||||
"""楼层信息"""
|
||||
|
||||
code: str = Field(..., description="楼层代码")
|
||||
name: str = Field(..., description="楼层名称")
|
||||
|
||||
|
||||
##############################################################
|
||||
# * 房间信息模型 *#
|
||||
##############################################################
|
||||
|
||||
|
||||
class RoomInfo(BaseModel):
|
||||
"""房间信息"""
|
||||
|
||||
code: str = Field(..., description="房间代码")
|
||||
name: str = Field(..., description="房间名称")
|
||||
|
||||
|
||||
###############################################################
|
||||
# * 楼栋-楼层-房间信息模型 *#
|
||||
###############################################################
|
||||
class CacheFloorData(BaseModel):
|
||||
"""缓存的楼层信息"""
|
||||
|
||||
code: str = Field(..., description="楼层代码")
|
||||
name: str = Field(..., description="楼层名称")
|
||||
rooms: List[RoomInfo] = Field(..., description="房间列表")
|
||||
|
||||
|
||||
class CacheBuildingData(BaseModel):
|
||||
"""缓存的楼栋信息"""
|
||||
|
||||
code: str = Field(..., description="楼栋代码")
|
||||
name: str = Field(..., description="楼栋名称")
|
||||
floors: List[CacheFloorData] = Field(..., description="楼层列表")
|
||||
|
||||
|
||||
class CacheRoomsData(BaseModel):
|
||||
"""缓存的寝室信息"""
|
||||
|
||||
datetime: str = Field(..., description="数据更新时间,格式:YYYY-MM-DD HH:MM:SS")
|
||||
data: List[CacheBuildingData] = Field(..., description="楼栋列表")
|
||||
|
||||
|
||||
class RoomBindingInfo(BaseModel):
|
||||
"""房间绑定信息"""
|
||||
|
||||
building: BuildingInfo
|
||||
floor: FloorInfo
|
||||
room: RoomInfo
|
||||
room_id: str = Field(..., description="完整房间ID")
|
||||
display_text: str = Field(
|
||||
..., description="显示文本,如:北苑11号学生公寓/11-6层/11-627"
|
||||
)
|
||||
|
||||
|
||||
##############################################################
|
||||
# * 获取当前宿舍响应模型 *#
|
||||
##############################################################
|
||||
|
||||
|
||||
class CurrentRoomResponse(BaseModel):
|
||||
"""获取当前宿舍响应模型"""
|
||||
|
||||
room_code: str = Field(..., description="房间代码")
|
||||
display_text: str = Field(
|
||||
..., description="显示文本,如:北苑11号学生公寓/11-6层/11-627"
|
||||
)
|
||||
|
||||
|
||||
##############################################################
|
||||
# * 强制刷新响应模型 *#
|
||||
##############################################################
|
||||
|
||||
|
||||
class ForceRefreshResponse(BaseModel):
|
||||
"""强制刷新响应模型"""
|
||||
|
||||
success: bool = Field(..., description="是否刷新成功")
|
||||
message: str = Field(..., description="响应消息")
|
||||
remaining_cooldown: float = Field(
|
||||
default=0.0, description="剩余冷却时间(秒),0表示无冷却"
|
||||
)
|
||||
|
||||
|
||||
##############################################################
|
||||
# * 楼层房间查询响应模型 *#
|
||||
##############################################################
|
||||
|
||||
|
||||
class FloorRoomsResponse(BaseModel):
|
||||
"""楼层房间查询响应模型"""
|
||||
|
||||
floor_code: str = Field(..., description="楼层代码")
|
||||
floor_name: str = Field(..., description="楼层名称")
|
||||
building_code: str = Field(..., description="所属楼栋代码")
|
||||
rooms: List[RoomInfo] = Field(..., description="房间列表")
|
||||
room_count: int = Field(..., description="房间数量")
|
||||
|
||||
|
||||
##############################################################
|
||||
# * 房间详情查询响应模型 *#
|
||||
##############################################################
|
||||
|
||||
|
||||
class RoomDetailResponse(BaseModel):
|
||||
"""房间详情查询响应模型"""
|
||||
|
||||
room_code: str = Field(..., description="房间代码")
|
||||
room_name: str = Field(..., description="房间名称")
|
||||
floor_code: str = Field(..., description="所属楼层代码")
|
||||
floor_name: str = Field(..., description="所属楼层名称")
|
||||
building_code: str = Field(..., description="所属楼栋代码")
|
||||
building_name: str = Field(..., description="所属楼栋名称")
|
||||
display_text: str = Field(..., description="完整显示文本")
|
||||
|
||||
|
||||
##############################################################
|
||||
# * 楼栋列表响应模型 *#
|
||||
##############################################################
|
||||
|
||||
|
||||
class BuildingListResponse(BaseModel):
|
||||
"""楼栋列表响应模型"""
|
||||
|
||||
buildings: List[BuildingInfo] = Field(..., description="楼栋列表")
|
||||
building_count: int = Field(..., description="楼栋数量")
|
||||
datetime: str = Field(..., description="数据更新时间")
|
||||
544
loveace/router/endpoint/isim/room.py
Normal file
544
loveace/router/endpoint/isim/room.py
Normal file
@@ -0,0 +1,544 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from loveace.database.auth.user import ACEUser
|
||||
from loveace.database.creator import get_db_session
|
||||
from loveace.database.isim.room import RoomBind
|
||||
from loveace.router.dependencies.auth import get_user_by_token
|
||||
from loveace.router.endpoint.isim.model.protect_router import ISIMRouterErrorToCode
|
||||
from loveace.router.endpoint.isim.model.room import (
|
||||
BindRoomRequest,
|
||||
BindRoomResponse,
|
||||
BuildingInfo,
|
||||
BuildingListResponse,
|
||||
CacheRoomsData,
|
||||
CurrentRoomResponse,
|
||||
FloorRoomsResponse,
|
||||
ForceRefreshResponse,
|
||||
RoomDetailResponse,
|
||||
)
|
||||
from loveace.router.endpoint.isim.utils.isim import ISIMClient, get_isim_client
|
||||
from loveace.router.endpoint.isim.utils.lock_manager import get_refresh_lock_manager
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
|
||||
isim_room_router = APIRouter(
|
||||
prefix="/room",
|
||||
responses=ISIMRouterErrorToCode.gen_code_table(),
|
||||
)
|
||||
|
||||
|
||||
@isim_room_router.get(
|
||||
"/list",
|
||||
summary="[完整数据] 获取所有楼栋、楼层、房间的完整树形结构",
|
||||
response_model=UniResponseModel[CacheRoomsData],
|
||||
)
|
||||
async def get_rooms(
|
||||
isim_conn: ISIMClient = Depends(get_isim_client),
|
||||
) -> UniResponseModel[CacheRoomsData] | JSONResponse:
|
||||
"""
|
||||
获取完整的寝室列表(所有楼栋、楼层、房间的树形结构)
|
||||
|
||||
⚠️ 数据量大:包含所有楼栋的完整数据,适合需要完整数据的场景
|
||||
💡 建议:移动端或需要部分数据的场景,请使用其他精细化查询接口
|
||||
"""
|
||||
try:
|
||||
rooms = await isim_conn.get_cached_rooms()
|
||||
if not rooms:
|
||||
return ISIMRouterErrorToCode().null_response.to_json_response(
|
||||
isim_conn.client.logger.trace_id
|
||||
)
|
||||
return UniResponseModel[CacheRoomsData](
|
||||
success=True,
|
||||
data=rooms,
|
||||
message="获取寝室列表成功",
|
||||
error=None,
|
||||
)
|
||||
except Exception as e:
|
||||
isim_conn.client.logger.error(f"获取寝室列表异常: {str(e)}")
|
||||
return ISIMRouterErrorToCode().server_error.to_json_response(
|
||||
isim_conn.client.logger.trace_id
|
||||
)
|
||||
|
||||
|
||||
@isim_room_router.get(
|
||||
"/list/buildings",
|
||||
summary="[轻量级] 获取所有楼栋列表(仅楼栋信息,不含楼层和房间)",
|
||||
response_model=UniResponseModel[BuildingListResponse],
|
||||
)
|
||||
async def get_all_buildings(
|
||||
isim_conn: ISIMClient = Depends(get_isim_client),
|
||||
) -> UniResponseModel[BuildingListResponse] | JSONResponse:
|
||||
"""
|
||||
获取所有楼栋列表(仅楼栋的代码和名称)
|
||||
|
||||
✅ 数据量小:只返回楼栋列表,不包含楼层和房间
|
||||
💡 使用场景:
|
||||
- 楼栋选择器
|
||||
- 第一级导航菜单
|
||||
- 需要快速获取楼栋列表的场景
|
||||
"""
|
||||
logger = isim_conn.client.logger
|
||||
try:
|
||||
# 从Hash缓存获取完整数据
|
||||
full_data = await isim_conn.get_cached_rooms()
|
||||
|
||||
if not full_data or not full_data.data:
|
||||
logger.warning("楼栋数据不存在")
|
||||
return ISIMRouterErrorToCode().null_response.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
|
||||
# 提取楼栋信息
|
||||
buildings = [
|
||||
{"code": building.code, "name": building.name}
|
||||
for building in full_data.data
|
||||
]
|
||||
|
||||
result = BuildingListResponse(
|
||||
buildings=[BuildingInfo(**b) for b in buildings],
|
||||
building_count=len(buildings),
|
||||
datetime=full_data.datetime,
|
||||
)
|
||||
|
||||
logger.info(f"成功获取楼栋列表,共 {len(buildings)} 个楼栋")
|
||||
return UniResponseModel[BuildingListResponse](
|
||||
success=True,
|
||||
data=result,
|
||||
message=f"获取楼栋列表成功,共 {len(buildings)} 个楼栋",
|
||||
error=None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取楼栋列表异常: {str(e)}")
|
||||
return ISIMRouterErrorToCode().server_error.to_json_response(logger.trace_id)
|
||||
|
||||
|
||||
@isim_room_router.get(
|
||||
"/list/building/{building_code}",
|
||||
summary="[按楼栋查询] 获取指定楼栋的所有楼层和房间",
|
||||
response_model=UniResponseModel[CacheRoomsData],
|
||||
)
|
||||
async def get_building_rooms(
|
||||
building_code: str, isim_conn: ISIMClient = Depends(get_isim_client)
|
||||
) -> UniResponseModel[CacheRoomsData] | JSONResponse:
|
||||
"""
|
||||
获取指定楼栋及其所有楼层和房间的完整数据
|
||||
|
||||
✅ 数据量适中:只返回单个楼栋的数据,比完整列表小90%+
|
||||
💡 使用场景:
|
||||
- 用户选择楼栋后,展示该楼栋的所有楼层和房间
|
||||
- 楼栋详情页
|
||||
- 减少移动端流量消耗
|
||||
|
||||
Args:
|
||||
building_code: 楼栋代码(如:01, 02, 11等)
|
||||
"""
|
||||
logger = isim_conn.client.logger
|
||||
try:
|
||||
# 使用Hash精细化查询,只获取指定楼栋
|
||||
building_data = await isim_conn.get_building_with_floors(building_code)
|
||||
|
||||
if not building_data:
|
||||
logger.warning(f"楼栋 {building_code} 不存在或无数据")
|
||||
return ISIMRouterErrorToCode().null_response.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
|
||||
# 构造响应数据
|
||||
import datetime
|
||||
|
||||
result = CacheRoomsData(
|
||||
datetime=datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
data=[building_data],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"成功获取楼栋 {building_code} 信息,"
|
||||
f"楼层数: {len(building_data.floors)}, "
|
||||
f"房间数: {sum(len(f.rooms) for f in building_data.floors)}"
|
||||
)
|
||||
return UniResponseModel[CacheRoomsData](
|
||||
success=True,
|
||||
data=result,
|
||||
message=f"获取楼栋 {building_code} 信息成功",
|
||||
error=None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取楼栋 {building_code} 信息异常: {str(e)}")
|
||||
return ISIMRouterErrorToCode().server_error.to_json_response(logger.trace_id)
|
||||
|
||||
|
||||
@isim_room_router.get(
|
||||
"/list/floor/{floor_code}",
|
||||
summary="[按楼层查询] 获取指定楼层的所有房间列表",
|
||||
response_model=UniResponseModel[FloorRoomsResponse],
|
||||
)
|
||||
async def get_floor_rooms(
|
||||
floor_code: str, isim_conn: ISIMClient = Depends(get_isim_client)
|
||||
) -> UniResponseModel[FloorRoomsResponse] | JSONResponse:
|
||||
"""
|
||||
获取指定楼层的所有房间信息
|
||||
|
||||
✅ 数据量最小:只返回单个楼层的房间列表,极小数据量
|
||||
💡 使用场景:
|
||||
- 用户选择楼层后,展示该楼层的所有房间
|
||||
- 房间选择器的第三级
|
||||
- 移动端分页加载
|
||||
- 需要最快响应速度的场景
|
||||
|
||||
Args:
|
||||
floor_code: 楼层代码(如:0101, 0102, 1101等)
|
||||
"""
|
||||
logger = isim_conn.client.logger
|
||||
try:
|
||||
# 获取楼层信息
|
||||
floor_info = await isim_conn.get_floor_info(floor_code)
|
||||
|
||||
if not floor_info:
|
||||
logger.warning(f"楼层 {floor_code} 不存在")
|
||||
return ISIMRouterErrorToCode().null_response.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
|
||||
# 获取房间列表(从Hash直接查询,非常快速)
|
||||
rooms = await isim_conn.get_rooms_by_floor(floor_code)
|
||||
|
||||
# 从楼层代码提取楼栋代码(前2位)
|
||||
building_code = floor_code[:2] if len(floor_code) >= 2 else ""
|
||||
|
||||
result = FloorRoomsResponse(
|
||||
floor_code=floor_info.code,
|
||||
floor_name=floor_info.name,
|
||||
building_code=building_code,
|
||||
rooms=rooms,
|
||||
room_count=len(rooms),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"成功获取楼层 {floor_code} ({floor_info.name}) 的房间信息,共 {len(rooms)} 个房间"
|
||||
)
|
||||
return UniResponseModel[FloorRoomsResponse](
|
||||
success=True,
|
||||
data=result,
|
||||
message=f"获取楼层 {floor_code} 的房间信息成功,共 {len(rooms)} 个房间",
|
||||
error=None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取楼层 {floor_code} 房间信息异常: {str(e)}")
|
||||
return ISIMRouterErrorToCode().server_error.to_json_response(logger.trace_id)
|
||||
|
||||
|
||||
@isim_room_router.get(
|
||||
"/info/{room_code}",
|
||||
summary="[房间详情] 获取单个房间的完整层级信息",
|
||||
response_model=UniResponseModel[RoomDetailResponse],
|
||||
)
|
||||
async def get_room_info(
|
||||
room_code: str, isim_conn: ISIMClient = Depends(get_isim_client)
|
||||
) -> UniResponseModel[RoomDetailResponse] | JSONResponse:
|
||||
"""
|
||||
获取指定房间的完整信息(包括楼栋、楼层、房间的完整层级结构)
|
||||
|
||||
✅ 功能强大:一次性返回房间的完整上下文信息
|
||||
💡 使用场景:
|
||||
- 房间详情页展示
|
||||
- 显示完整的 "楼栋/楼层/房间" 路径
|
||||
- 房间搜索结果展示
|
||||
- 需要房间完整信息的场景
|
||||
|
||||
Args:
|
||||
room_code: 房间代码(如:010101, 110627等)
|
||||
"""
|
||||
logger = isim_conn.client.logger
|
||||
try:
|
||||
# 提取层级代码
|
||||
if len(room_code) < 4:
|
||||
logger.warning(f"房间代码 {room_code} 格式错误")
|
||||
return ISIMRouterErrorToCode().null_response.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
|
||||
building_code = room_code[:2]
|
||||
floor_code = room_code[:4]
|
||||
|
||||
# 并发获取所有需要的信息
|
||||
import asyncio
|
||||
|
||||
building_info, floor_info, room_info = await asyncio.gather(
|
||||
isim_conn.get_building_info(building_code),
|
||||
isim_conn.get_floor_info(floor_code),
|
||||
isim_conn.query_room_info_fast(room_code),
|
||||
)
|
||||
|
||||
if not room_info:
|
||||
logger.warning(f"房间 {room_code} 不存在")
|
||||
return ISIMRouterErrorToCode().null_response.to_json_response(
|
||||
logger.trace_id
|
||||
)
|
||||
|
||||
# 构造显示文本
|
||||
building_name = building_info.name if building_info else "未知楼栋"
|
||||
floor_name = floor_info.name if floor_info else "未知楼层"
|
||||
display_text = f"{building_name}/{floor_name}/{room_info.name}"
|
||||
|
||||
result = RoomDetailResponse(
|
||||
room_code=room_info.code,
|
||||
room_name=room_info.name,
|
||||
floor_code=floor_code,
|
||||
floor_name=floor_name,
|
||||
building_code=building_code,
|
||||
building_name=building_name,
|
||||
display_text=display_text,
|
||||
)
|
||||
|
||||
logger.info(f"成功获取房间 {room_code} 的详细信息: {display_text}")
|
||||
return UniResponseModel[RoomDetailResponse](
|
||||
success=True,
|
||||
data=result,
|
||||
message=f"获取房间 {room_code} 的详细信息成功",
|
||||
error=None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取房间 {room_code} 详细信息异常: {str(e)}")
|
||||
return ISIMRouterErrorToCode().server_error.to_json_response(logger.trace_id)
|
||||
|
||||
|
||||
@isim_room_router.post(
|
||||
"/bind",
|
||||
summary="[用户操作] 绑定寝室到当前用户",
|
||||
response_model=UniResponseModel[BindRoomResponse],
|
||||
)
|
||||
async def bind_room(
|
||||
bind_request: BindRoomRequest,
|
||||
isim_conn: ISIMClient = Depends(get_isim_client),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UniResponseModel[BindRoomResponse] | JSONResponse:
|
||||
"""
|
||||
绑定寝室到当前用户(存在即更新)
|
||||
|
||||
💡 使用场景:
|
||||
- 用户首次绑定寝室
|
||||
- 用户更换寝室
|
||||
- 修改绑定信息
|
||||
"""
|
||||
logger = isim_conn.client.logger
|
||||
try:
|
||||
exist = await db.execute(
|
||||
select(RoomBind).where(RoomBind.user_id == isim_conn.client.userid)
|
||||
)
|
||||
exist = exist.scalars().first()
|
||||
if exist:
|
||||
if exist.roomid == bind_request.room_id:
|
||||
return UniResponseModel[BindRoomResponse](
|
||||
success=True,
|
||||
data=BindRoomResponse(success=True),
|
||||
message="宿舍绑定成功",
|
||||
error=None,
|
||||
)
|
||||
else:
|
||||
# 使用快速查询方法(从Hash直接获取,无需遍历完整树)
|
||||
room_info = await isim_conn.query_room_info_fast(bind_request.room_id)
|
||||
roomtext = room_info.name if room_info else None
|
||||
|
||||
# 如果Hash中没有,回退到完整查询
|
||||
if not roomtext:
|
||||
roomtext = await isim_conn.query_room_name(bind_request.room_id)
|
||||
|
||||
await db.execute(
|
||||
update(RoomBind)
|
||||
.where(RoomBind.user_id == isim_conn.client.userid)
|
||||
.values(roomid=bind_request.room_id, roomtext=roomtext)
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(f"更新寝室绑定成功: {roomtext}({bind_request.room_id})")
|
||||
return UniResponseModel[BindRoomResponse](
|
||||
success=True,
|
||||
data=BindRoomResponse(success=True),
|
||||
message="宿舍绑定成功",
|
||||
error=None,
|
||||
)
|
||||
else:
|
||||
# 使用快速查询方法(从Hash直接获取,无需遍历完整树)
|
||||
room_info = await isim_conn.query_room_info_fast(bind_request.room_id)
|
||||
roomtext = room_info.name if room_info else None
|
||||
|
||||
# 如果Hash中没有,回退到完整查询
|
||||
if not roomtext:
|
||||
roomtext = await isim_conn.query_room_name(bind_request.room_id)
|
||||
|
||||
new_bind = RoomBind(
|
||||
user_id=isim_conn.client.userid,
|
||||
roomid=bind_request.room_id,
|
||||
roomtext=roomtext,
|
||||
)
|
||||
db.add(new_bind)
|
||||
await db.commit()
|
||||
logger.info(f"新增寝室绑定成功: {roomtext}({bind_request.room_id})")
|
||||
return UniResponseModel[BindRoomResponse](
|
||||
success=True,
|
||||
data=BindRoomResponse(success=True),
|
||||
message="宿舍绑定成功",
|
||||
error=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"绑定寝室异常: {str(e)}")
|
||||
return ISIMRouterErrorToCode().server_error.to_json_response(
|
||||
isim_conn.client.logger.trace_id
|
||||
)
|
||||
|
||||
|
||||
@isim_room_router.get(
|
||||
"/current",
|
||||
summary="[用户查询] 获取当前用户绑定的宿舍信息",
|
||||
response_model=UniResponseModel[CurrentRoomResponse],
|
||||
)
|
||||
async def get_current_room(
|
||||
user: ACEUser = Depends(get_user_by_token),
|
||||
isim_conn: ISIMClient = Depends(get_isim_client),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UniResponseModel[CurrentRoomResponse] | JSONResponse:
|
||||
"""
|
||||
获取当前用户绑定的宿舍信息,返回 room_code 和 display_text
|
||||
|
||||
💡 使用场景:
|
||||
- 个人中心显示已绑定宿舍
|
||||
- 查询当前用户的寝室信息
|
||||
- 验证用户是否已绑定寝室
|
||||
"""
|
||||
logger = isim_conn.client.logger
|
||||
try:
|
||||
# 查询用户绑定的房间
|
||||
result = await db.execute(
|
||||
select(RoomBind).where(RoomBind.user_id == user.userid)
|
||||
)
|
||||
room_bind = result.scalars().first()
|
||||
|
||||
if not room_bind:
|
||||
logger.warning(f"用户 {user.userid} 未绑定宿舍")
|
||||
return UniResponseModel[CurrentRoomResponse](
|
||||
success=True,
|
||||
data=CurrentRoomResponse(
|
||||
room_code="",
|
||||
display_text="",
|
||||
),
|
||||
message="获取宿舍信息成功,用户未绑定宿舍",
|
||||
error=None,
|
||||
)
|
||||
|
||||
# 优先从Hash缓存快速获取房间显示文本
|
||||
display_text = await isim_conn.get_room_display_text(room_bind.roomid)
|
||||
if not display_text:
|
||||
# 如果缓存中没有,使用数据库中存储的文本
|
||||
display_text = room_bind.roomtext
|
||||
logger.debug(
|
||||
f"Hash缓存中未找到房间 {room_bind.roomid},使用数据库存储的文本"
|
||||
)
|
||||
|
||||
logger.info(f"成功获取用户 {user.userid} 的宿舍信息: {display_text}")
|
||||
return UniResponseModel[CurrentRoomResponse](
|
||||
success=True,
|
||||
data=CurrentRoomResponse(
|
||||
room_code=room_bind.roomid,
|
||||
display_text=display_text,
|
||||
),
|
||||
message="获取宿舍信息成功",
|
||||
error=None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取当前宿舍异常: {str(e)}")
|
||||
return ISIMRouterErrorToCode().server_error.to_json_response(logger.trace_id)
|
||||
|
||||
|
||||
@isim_room_router.post(
|
||||
"/refresh",
|
||||
summary="[管理操作] 强制刷新房间列表缓存",
|
||||
response_model=UniResponseModel[ForceRefreshResponse],
|
||||
)
|
||||
async def force_refresh_rooms(
|
||||
isim_conn: ISIMClient = Depends(get_isim_client),
|
||||
) -> UniResponseModel[ForceRefreshResponse] | JSONResponse:
|
||||
"""
|
||||
强制刷新房间列表缓存(从ISIM系统重新获取数据)
|
||||
|
||||
⚠️ 限制:
|
||||
- 使用全局锁确保同一时间只有一个请求在执行刷新操作
|
||||
- 刷新完成后有30分钟的冷却时间
|
||||
|
||||
💡 使用场景:
|
||||
- 发现数据不准确时手动刷新
|
||||
- 管理员更新缓存数据
|
||||
- 调试和测试
|
||||
"""
|
||||
logger = isim_conn.client.logger
|
||||
lock_manager = get_refresh_lock_manager()
|
||||
|
||||
try:
|
||||
# 尝试获取锁
|
||||
acquired, remaining_cooldown = await lock_manager.try_acquire()
|
||||
|
||||
if not acquired:
|
||||
if remaining_cooldown is not None:
|
||||
# 在冷却期内
|
||||
minutes = int(remaining_cooldown // 60)
|
||||
seconds = int(remaining_cooldown % 60)
|
||||
message = f"刷新操作冷却中,请在 {minutes} 分 {seconds} 秒后重试"
|
||||
logger.warning(f"刷新请求被拒绝: {message}")
|
||||
return UniResponseModel[ForceRefreshResponse](
|
||||
success=False,
|
||||
data=ForceRefreshResponse(
|
||||
success=False,
|
||||
message=message,
|
||||
remaining_cooldown=remaining_cooldown,
|
||||
),
|
||||
message=message,
|
||||
error=None,
|
||||
)
|
||||
else:
|
||||
# 有其他人正在刷新
|
||||
message = "其他用户正在刷新房间列表,请稍后再试"
|
||||
logger.warning(message)
|
||||
return UniResponseModel[ForceRefreshResponse](
|
||||
success=False,
|
||||
data=ForceRefreshResponse(
|
||||
success=False,
|
||||
message=message,
|
||||
remaining_cooldown=0.0,
|
||||
),
|
||||
message=message,
|
||||
error=None,
|
||||
)
|
||||
|
||||
# 成功获取锁,执行刷新操作
|
||||
try:
|
||||
logger.info("开始强制刷新房间列表缓存")
|
||||
await isim_conn.force_refresh_room_cache()
|
||||
logger.info("房间列表缓存刷新完成")
|
||||
|
||||
return UniResponseModel[ForceRefreshResponse](
|
||||
success=True,
|
||||
data=ForceRefreshResponse(
|
||||
success=True,
|
||||
message="房间列表刷新成功",
|
||||
remaining_cooldown=0.0,
|
||||
),
|
||||
message="房间列表刷新成功",
|
||||
error=None,
|
||||
)
|
||||
|
||||
finally:
|
||||
# 释放锁并设置冷却时间
|
||||
lock_manager.release()
|
||||
logger.info("刷新锁已释放,冷却时间已设置")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"强制刷新房间列表异常: {str(e)}")
|
||||
# 确保异常时也释放锁
|
||||
if lock_manager.is_refreshing():
|
||||
lock_manager.release()
|
||||
return ISIMRouterErrorToCode().server_error.to_json_response(logger.trace_id)
|
||||
1425
loveace/router/endpoint/isim/utils/isim.py
Normal file
1425
loveace/router/endpoint/isim/utils/isim.py
Normal file
File diff suppressed because it is too large
Load Diff
109
loveace/router/endpoint/isim/utils/lock_manager.py
Normal file
109
loveace/router/endpoint/isim/utils/lock_manager.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
全局锁管理器模块
|
||||
用于管理需要冷却时间(CD)的操作锁
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class RefreshLockManager:
|
||||
"""刷新操作锁管理器,确保一次只能执行一个刷新操作,且有30分钟的冷却时间"""
|
||||
|
||||
def __init__(self, cooldown_seconds: int = 1800): # 默认30分钟 = 1800秒
|
||||
"""
|
||||
初始化锁管理器
|
||||
|
||||
Args:
|
||||
cooldown_seconds: 冷却时间(秒),默认为1800秒(30分钟)
|
||||
"""
|
||||
self._lock = asyncio.Lock()
|
||||
self._last_refresh_time: Optional[float] = None
|
||||
self._cooldown_seconds = cooldown_seconds
|
||||
self._is_refreshing = False
|
||||
|
||||
async def try_acquire(self) -> tuple[bool, Optional[float]]:
|
||||
"""
|
||||
尝试获取锁并检查冷却时间
|
||||
|
||||
Returns:
|
||||
tuple[bool, Optional[float]]:
|
||||
- bool: 是否成功获取锁(未在冷却期且未被占用)
|
||||
- Optional[float]: 如果在冷却期,返回剩余冷却时间(秒),否则为None
|
||||
"""
|
||||
# 检查是否有其他人正在刷新
|
||||
if self._is_refreshing:
|
||||
return False, None
|
||||
|
||||
# 检查冷却时间
|
||||
if self._last_refresh_time is not None:
|
||||
elapsed = time.time() - self._last_refresh_time
|
||||
if elapsed < self._cooldown_seconds:
|
||||
remaining_cooldown = self._cooldown_seconds - elapsed
|
||||
return False, remaining_cooldown
|
||||
|
||||
# 尝试获取锁(非阻塞)
|
||||
acquired = not self._lock.locked()
|
||||
if acquired:
|
||||
await self._lock.acquire()
|
||||
self._is_refreshing = True
|
||||
|
||||
return acquired, None
|
||||
|
||||
def release(self):
|
||||
"""
|
||||
释放锁并更新最后刷新时间
|
||||
"""
|
||||
self._last_refresh_time = time.time()
|
||||
self._is_refreshing = False
|
||||
if self._lock.locked():
|
||||
self._lock.release()
|
||||
|
||||
def get_remaining_cooldown(self) -> Optional[float]:
|
||||
"""
|
||||
获取剩余冷却时间
|
||||
|
||||
Returns:
|
||||
Optional[float]: 剩余冷却时间(秒),如果不在冷却期则返回None
|
||||
"""
|
||||
if self._last_refresh_time is None:
|
||||
return None
|
||||
|
||||
elapsed = time.time() - self._last_refresh_time
|
||||
if elapsed < self._cooldown_seconds:
|
||||
return self._cooldown_seconds - elapsed
|
||||
|
||||
return None
|
||||
|
||||
def is_in_cooldown(self) -> bool:
|
||||
"""
|
||||
检查是否在冷却期内
|
||||
|
||||
Returns:
|
||||
bool: 是否在冷却期内
|
||||
"""
|
||||
return self.get_remaining_cooldown() is not None
|
||||
|
||||
def is_refreshing(self) -> bool:
|
||||
"""
|
||||
检查是否正在刷新
|
||||
|
||||
Returns:
|
||||
bool: 是否正在刷新
|
||||
"""
|
||||
return self._is_refreshing
|
||||
|
||||
|
||||
# 全局单例实例
|
||||
_refresh_lock_manager = RefreshLockManager(cooldown_seconds=1800) # 30分钟
|
||||
|
||||
|
||||
def get_refresh_lock_manager() -> RefreshLockManager:
|
||||
"""
|
||||
获取全局刷新锁管理器实例
|
||||
|
||||
Returns:
|
||||
RefreshLockManager: 全局锁管理器实例
|
||||
"""
|
||||
return _refresh_lock_manager
|
||||
24
loveace/router/endpoint/isim/utils/room.py
Normal file
24
loveace/router/endpoint/isim/utils/room.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from fastapi import Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from loveace.database.creator import get_db_session
|
||||
from loveace.database.isim.room import RoomBind
|
||||
from loveace.router.endpoint.isim.model.protect_router import ISIMRouterErrorToCode
|
||||
from loveace.router.endpoint.isim.utils.isim import ISIMClient, get_isim_client
|
||||
|
||||
|
||||
async def get_bound_room(
|
||||
isim_conn: ISIMClient = Depends(get_isim_client),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RoomBind:
|
||||
"""获取已绑定的寝室"""
|
||||
result = await db.execute(
|
||||
select(RoomBind).where(RoomBind.user_id == isim_conn.client.userid)
|
||||
)
|
||||
bound_room = result.scalars().first()
|
||||
if not bound_room:
|
||||
raise ISIMRouterErrorToCode.UNBOUNDROOM.to_http_exception(
|
||||
isim_conn.client.logger.trace_id
|
||||
)
|
||||
return bound_room
|
||||
18
loveace/router/endpoint/jwc/__init__.py
Normal file
18
loveace/router/endpoint/jwc/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from loveace.router.endpoint.jwc.academic import jwc_academic_router
|
||||
from loveace.router.endpoint.jwc.competition import jwc_competition_router
|
||||
from loveace.router.endpoint.jwc.exam import jwc_exam_router
|
||||
from loveace.router.endpoint.jwc.plan import jwc_plan_router
|
||||
from loveace.router.endpoint.jwc.schedule import jwc_schedules_router
|
||||
from loveace.router.endpoint.jwc.score import jwc_score_router
|
||||
from loveace.router.endpoint.jwc.term import jwc_term_router
|
||||
|
||||
jwc_base_router = APIRouter(prefix="/jwc", tags=["教务处"])
|
||||
jwc_base_router.include_router(jwc_exam_router)
|
||||
jwc_base_router.include_router(jwc_academic_router)
|
||||
jwc_base_router.include_router(jwc_term_router)
|
||||
jwc_base_router.include_router(jwc_score_router)
|
||||
jwc_base_router.include_router(jwc_plan_router)
|
||||
jwc_base_router.include_router(jwc_schedules_router)
|
||||
jwc_base_router.include_router(jwc_competition_router)
|
||||
245
loveace/router/endpoint/jwc/academic.py
Normal file
245
loveace/router/endpoint/jwc/academic.py
Normal file
@@ -0,0 +1,245 @@
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from loveace.router.endpoint.jwc.model.academic import (
|
||||
AcademicInfo,
|
||||
AcademicInfoTransformer,
|
||||
CourseSelectionStatus,
|
||||
CourseSelectionStatusTransformer,
|
||||
TrainingPlanInfo,
|
||||
TrainingPlanInfoTransformer,
|
||||
)
|
||||
from loveace.router.endpoint.jwc.model.base import JWCConfig
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
jwc_academic_router = APIRouter(
|
||||
prefix="/academic",
|
||||
responses=ProtectRouterErrorToCode.gen_code_table(),
|
||||
)
|
||||
|
||||
|
||||
ENDPOINTS = {
|
||||
"academic_info": "/main/academicInfo?sf_request_type=ajax",
|
||||
"training_plan": "/main/showPyfaInfo?sf_request_type=ajax",
|
||||
"course_selection_status": "/main/checkSelectCourseStatus?sf_request_type=ajax",
|
||||
}
|
||||
|
||||
|
||||
@jwc_academic_router.get(
|
||||
"/info", response_model=UniResponseModel[AcademicInfo], summary="获取学业信息"
|
||||
)
|
||||
async def get_academic_info(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[AcademicInfo] | JSONResponse:
|
||||
"""
|
||||
获取用户的学业信息(GPA、学分等)
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取当前学期学业情况
|
||||
- 获取平均学分绩点(GPA)
|
||||
- 实时从教务系统查询
|
||||
|
||||
💡 使用场景:
|
||||
- 个人中心查看学业成绩概览
|
||||
- 了解学业进展情况
|
||||
- 毕业时验证学业要求
|
||||
|
||||
Returns:
|
||||
AcademicInfo: 包含 GPA、学分、学业状态等信息
|
||||
"""
|
||||
try:
|
||||
conn.logger.info(f"获取用户 {conn.userid} 的学业信息")
|
||||
academic_info = await conn.client.post(
|
||||
JWCConfig().to_full_url(ENDPOINTS["academic_info"]),
|
||||
data={"flag": ""},
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
if not academic_info.status_code == 200:
|
||||
conn.logger.error(
|
||||
f"获取用户 {conn.userid} 的学业信息失败,状态码: {academic_info.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
try:
|
||||
data = academic_info.json()
|
||||
# 数组格式特殊处理
|
||||
data_to_validate = data[0]
|
||||
result = AcademicInfoTransformer.model_validate(
|
||||
data_to_validate
|
||||
).to_academic_info()
|
||||
return UniResponseModel[AcademicInfo](
|
||||
success=True,
|
||||
data=result,
|
||||
message="获取学业信息成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error("数据验证失败")
|
||||
conn.logger.debug(f"数据验证失败详情: {ve}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
|
||||
@jwc_academic_router.get(
|
||||
"/training_plan",
|
||||
response_model=UniResponseModel[TrainingPlanInfo],
|
||||
summary="获取培养方案信息",
|
||||
)
|
||||
async def get_training_plan_info(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[TrainingPlanInfo] | JSONResponse:
|
||||
"""
|
||||
获取用户的培养方案信息
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取所属专业的培养方案
|
||||
- 获取年级和专业名称
|
||||
- 提取关键信息(年级、专业)
|
||||
|
||||
💡 使用场景:
|
||||
- 了解培养方案要求
|
||||
- 查看所属年级和专业
|
||||
- 课程规划参考
|
||||
|
||||
Returns:
|
||||
TrainingPlanInfo: 包含方案名称、专业名称、年级信息
|
||||
"""
|
||||
try:
|
||||
conn.logger.info(f"获取用户 {conn.userid} 的培养方案信息")
|
||||
training_plan_info = await conn.client.get(
|
||||
JWCConfig().to_full_url(ENDPOINTS["training_plan"]),
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
if not training_plan_info.status_code == 200:
|
||||
conn.logger.error(
|
||||
f"获取用户 {conn.userid} 的培养方案信息失败,状态码: {training_plan_info.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
try:
|
||||
data = training_plan_info.json()
|
||||
transformer = TrainingPlanInfoTransformer.model_validate(data)
|
||||
if transformer.count > 0 and len(transformer.data) > 0:
|
||||
first_plan = transformer.data[0]
|
||||
if len(first_plan) >= 2:
|
||||
plan_name = first_plan[0]
|
||||
# 提取年级信息 - 假设格式为"20XX级..."
|
||||
grade_match = re.search(r"(\d{4})级", plan_name)
|
||||
grade = grade_match.group(1) if grade_match else ""
|
||||
|
||||
# 提取专业名称 - 假设格式为"20XX级XXX本科培养方案"
|
||||
major_match = re.search(r"\d{4}级(.+?)本科", plan_name)
|
||||
major_name = major_match.group(1) if major_match else ""
|
||||
result = TrainingPlanInfo(
|
||||
plan_name=plan_name, major_name=major_name, grade=grade
|
||||
)
|
||||
return UniResponseModel[TrainingPlanInfo](
|
||||
success=True,
|
||||
data=result,
|
||||
message="获取培养方案信息成功",
|
||||
error=None,
|
||||
)
|
||||
else:
|
||||
conn.logger.error("培养方案数据格式不正确,字段数量不足")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
else:
|
||||
conn.logger.error("培养方案数据为空")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error("数据验证失败")
|
||||
conn.logger.debug(f"数据验证失败详情: {ve}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
|
||||
@jwc_academic_router.get(
|
||||
"/course_selection_status",
|
||||
response_model=UniResponseModel[CourseSelectionStatus],
|
||||
summary="获取选课状态信息",
|
||||
)
|
||||
async def get_course_selection_status(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[CourseSelectionStatus] | JSONResponse:
|
||||
"""
|
||||
获取用户的选课状态
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取当前选课时间窗口
|
||||
- 获取选课开放状态
|
||||
- 显示选课时间提醒
|
||||
|
||||
💡 使用场景:
|
||||
- 查看当前是否在选课时间内
|
||||
- 获取选课开始和结束时间
|
||||
- 选课前的状态检查
|
||||
|
||||
Returns:
|
||||
CourseSelectionStatus: 包含选课状态、开始时间、结束时间等
|
||||
"""
|
||||
try:
|
||||
conn.logger.info(f"获取用户 {conn.userid} 的选课状态信息")
|
||||
course_selection_status = await conn.client.get(
|
||||
JWCConfig().to_full_url(ENDPOINTS["course_selection_status"]),
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
if not course_selection_status.status_code == 200:
|
||||
conn.logger.error(
|
||||
f"获取用户 {conn.userid} 的选课状态信息失败,状态码: {course_selection_status.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
try:
|
||||
data = course_selection_status.json()
|
||||
result = CourseSelectionStatus(
|
||||
can_select=(
|
||||
True
|
||||
if CourseSelectionStatusTransformer.model_validate(data).status_code
|
||||
== "1"
|
||||
else False
|
||||
)
|
||||
)
|
||||
return UniResponseModel[CourseSelectionStatus](
|
||||
success=True,
|
||||
data=result,
|
||||
message="获取选课状态成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error("数据验证失败")
|
||||
conn.logger.debug(f"数据验证失败详情: {ve}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
121
loveace/router/endpoint/jwc/competition.py
Normal file
121
loveace/router/endpoint/jwc/competition.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from loveace.router.endpoint.jwc.model.competition import (
|
||||
CompetitionFullResponse,
|
||||
)
|
||||
from loveace.router.endpoint.jwc.utils.aspnet_form_parser import ASPNETFormParser
|
||||
from loveace.router.endpoint.jwc.utils.competition import CompetitionInfoParser
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
jwc_competition_router = APIRouter(
|
||||
prefix="/competition",
|
||||
responses=ProtectRouterErrorToCode().gen_code_table(),
|
||||
)
|
||||
|
||||
ENDPOINT = {
|
||||
"awards_page": "http://211-86-241-245.vpn2.aufe.edu.cn:8118/xsXmMain.aspx",
|
||||
}
|
||||
|
||||
|
||||
@jwc_competition_router.get(
|
||||
"/info",
|
||||
summary="获取完整学科竞赛信息",
|
||||
response_model=UniResponseModel[CompetitionFullResponse],
|
||||
)
|
||||
async def get_full_competition_info(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[CompetitionFullResponse] | JSONResponse:
|
||||
"""
|
||||
获取用户的完整学科竞赛信息(一次请求获取所有数据)
|
||||
|
||||
✅ 功能特性:
|
||||
- 一次请求获取获奖项目列表和学分汇总
|
||||
- 减少网络IO调用,提高性能
|
||||
- 返回完整的竞赛相关数据
|
||||
|
||||
📊 返回数据:
|
||||
- 获奖项目列表(包含项目信息、学分、奖励等)
|
||||
- 学分汇总(各类学分统计)
|
||||
- 学生基本信息
|
||||
|
||||
💡 使用场景:
|
||||
- 需要完整竞赛信息的仪表板
|
||||
- 移动端应用(减少请求次数)
|
||||
- 性能敏感的场景
|
||||
|
||||
Returns:
|
||||
CompetitionFullResponse: 包含完整竞赛信息的响应对象
|
||||
"""
|
||||
try:
|
||||
conn.logger.info(f"获取用户 {conn.userid} 的完整学科竞赛信息")
|
||||
|
||||
# 第一次访问页面获取 HTML 内容和 Cookie
|
||||
conn.logger.debug("第一次访问创新创业管理平台页面获取表单数据")
|
||||
index_response = await conn.client.get(
|
||||
ENDPOINT["awards_page"],
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
|
||||
if index_response.status_code != 200:
|
||||
conn.logger.error(f"第一次访问创新创业管理平台失败,状态码: {index_response.status_code}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
# 从第一次响应中提取动态表单数据
|
||||
conn.logger.debug("从页面中提取动态表单数据")
|
||||
try:
|
||||
form_data = ASPNETFormParser.get_awards_list_form_data(index_response.text)
|
||||
conn.logger.debug(f"成功提取表单数据,__VIEWSTATE 长度: {len(form_data.get('__VIEWSTATE', ''))}")
|
||||
except Exception as e:
|
||||
conn.logger.error(f"提取表单数据失败: {e}")
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
# 第二次请求:使用动态表单数据请求已申报奖项页面
|
||||
conn.logger.debug("使用动态表单数据请求已申报奖项页面")
|
||||
result_response = await conn.client.post(
|
||||
ENDPOINT["awards_page"],
|
||||
follow_redirects=True,
|
||||
data=form_data,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
|
||||
if result_response.status_code != 200:
|
||||
conn.logger.error(f"请求已申报奖项页面失败,状态码: {result_response.status_code}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
# 一次性解析所有数据
|
||||
parser = CompetitionInfoParser(result_response.text)
|
||||
full_response = parser.parse_full_competition_info()
|
||||
|
||||
conn.logger.info(
|
||||
f"成功获取用户 {conn.userid} 的完整竞赛信息,共 {full_response.total_awards_count} 项获奖"
|
||||
)
|
||||
|
||||
return UniResponseModel[CompetitionFullResponse](
|
||||
success=True,
|
||||
data=full_response,
|
||||
message="获取竞赛信息成功",
|
||||
error=None,
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
conn.logger.error(f"用户 {conn.userid} 的竞赛信息数据验证失败: {e}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"用户 {conn.userid} 的完整竞赛信息获取失败: {e}")
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
97
loveace/router/endpoint/jwc/exam.py
Normal file
97
loveace/router/endpoint/jwc/exam.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from loveace.router.endpoint.jwc.academic import get_academic_info
|
||||
from loveace.router.endpoint.jwc.model.academic import AcademicInfo
|
||||
from loveace.router.endpoint.jwc.model.exam import ExamInfoResponse
|
||||
from loveace.router.endpoint.jwc.utils.exam import fetch_unified_exam_info
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
jwc_exam_router = APIRouter(
|
||||
prefix="/exam",
|
||||
responses=ProtectRouterErrorToCode().gen_code_table(),
|
||||
)
|
||||
|
||||
|
||||
@jwc_exam_router.get(
|
||||
"/info", response_model=UniResponseModel[ExamInfoResponse], summary="获取考试信息"
|
||||
)
|
||||
async def get_exam_info(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[ExamInfoResponse] | JSONResponse:
|
||||
"""
|
||||
获取用户的考试信息
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取当前学期的考试安排
|
||||
- 自动确定考试时间范围
|
||||
- 显示考试时间、地点、课程等信息
|
||||
|
||||
💡 使用场景:
|
||||
- 查看即将进行的考试
|
||||
- 了解考试安排和地点
|
||||
- 提前规划复习计划
|
||||
|
||||
Returns:
|
||||
ExamInfoResponse: 包含考试列表和总数
|
||||
"""
|
||||
try:
|
||||
academic_info = await get_academic_info(conn)
|
||||
if isinstance(academic_info, UniResponseModel):
|
||||
if academic_info.data and isinstance(academic_info.data, AcademicInfo):
|
||||
term_code = academic_info.data.current_term
|
||||
else:
|
||||
result = ExamInfoResponse(exams=[], total_count=0)
|
||||
return UniResponseModel[ExamInfoResponse](
|
||||
success=False,
|
||||
data=result,
|
||||
message="无法获取学期信息",
|
||||
error=None,
|
||||
)
|
||||
elif isinstance(academic_info, AcademicInfo):
|
||||
term_code = academic_info.current_term
|
||||
else:
|
||||
result = ExamInfoResponse(exams=[], total_count=0)
|
||||
return UniResponseModel[ExamInfoResponse](
|
||||
success=False,
|
||||
data=result,
|
||||
message="无法获取学期信息",
|
||||
error=None,
|
||||
)
|
||||
conn.logger.info(f"获取用户 {conn.userid} 的考试信息")
|
||||
|
||||
start_date = datetime.now()
|
||||
# termcode 结尾为 1 为秋季学期,考试应在3月之前,2为春季学期,考试应在9月之前
|
||||
end_date = datetime(
|
||||
year=start_date.year + (1 if term_code.endswith("1") else 0),
|
||||
month=3 if term_code.endswith("1") else 9,
|
||||
day=30,
|
||||
)
|
||||
exam_info = await fetch_unified_exam_info(
|
||||
conn,
|
||||
start_date=start_date.strftime("%Y-%m-%d"),
|
||||
end_date=end_date.strftime("%Y-%m-%d"),
|
||||
term_code=term_code,
|
||||
)
|
||||
return UniResponseModel[ExamInfoResponse](
|
||||
success=True,
|
||||
data=exam_info,
|
||||
message="获取考试信息成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as e:
|
||||
conn.logger.error(f"用户 {conn.userid} 的考试信息数据验证失败: {e}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"用户 {conn.userid} 的考试信息获取失败: {e}")
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
67
loveace/router/endpoint/jwc/model/academic.py
Normal file
67
loveace/router/endpoint/jwc/model/academic.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from loveace.router.endpoint.jwc.utils.zxjxjhh_to_term_format import (
|
||||
convert_zxjxjhh_to_term_format,
|
||||
)
|
||||
|
||||
|
||||
class AcademicInfoTransformer(BaseModel):
|
||||
"""学术信息数据项"""
|
||||
|
||||
completed_courses: int = Field(0, alias="courseNum")
|
||||
failed_courses: int = Field(0, alias="coursePas")
|
||||
gpa: float = Field(0, alias="gpa")
|
||||
current_term: str = Field("", alias="zxjxjhh")
|
||||
pending_courses: int = Field(0, alias="courseNum_bxqyxd")
|
||||
|
||||
def to_academic_info(self) -> "AcademicInfo":
|
||||
"""转换为 AcademicInfo"""
|
||||
return AcademicInfo(
|
||||
completed_courses=self.completed_courses,
|
||||
failed_courses=self.failed_courses,
|
||||
pending_courses=self.pending_courses,
|
||||
gpa=self.gpa,
|
||||
current_term=self.current_term,
|
||||
current_term_name=convert_zxjxjhh_to_term_format(self.current_term),
|
||||
)
|
||||
|
||||
|
||||
class AcademicInfo(BaseModel):
|
||||
"""学术信息数据模型"""
|
||||
|
||||
completed_courses: int = Field(0, description="已修课程数")
|
||||
failed_courses: int = Field(0, description="不及格课程数")
|
||||
pending_courses: int = Field(0, description="本学期待修课程数")
|
||||
gpa: float = Field(0, description="绩点")
|
||||
current_term: str = Field("", description="当前学期")
|
||||
current_term_name: str = Field("", description="当前学期名称")
|
||||
|
||||
|
||||
class TrainingPlanInfoTransformer(BaseModel):
|
||||
"""培养方案响应模型"""
|
||||
|
||||
count: int = 0
|
||||
data: List[List[str]] = []
|
||||
|
||||
|
||||
class TrainingPlanInfo(BaseModel):
|
||||
"""培养方案信息模型"""
|
||||
|
||||
plan_name: str = Field("", description="培养方案名称")
|
||||
major_name: str = Field("", description="专业名称")
|
||||
grade: str = Field("", description="年级")
|
||||
|
||||
|
||||
class CourseSelectionStatusTransformer(BaseModel):
|
||||
"""选课状态响应模型新格式"""
|
||||
|
||||
term_name: str = Field("", alias="zxjxjhm")
|
||||
status_code: str = Field("", alias="retString")
|
||||
|
||||
|
||||
class CourseSelectionStatus(BaseModel):
|
||||
"""选课状态信息"""
|
||||
|
||||
can_select: bool = Field(False, description="是否可以选课")
|
||||
10
loveace/router/endpoint/jwc/model/base.py
Normal file
10
loveace/router/endpoint/jwc/model/base.py
Normal file
@@ -0,0 +1,10 @@
|
||||
class JWCConfig:
|
||||
"""教务系统配置常量"""
|
||||
|
||||
DEFAULT_BASE_URL = "http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/"
|
||||
|
||||
def to_full_url(self, path: str) -> str:
|
||||
"""将路径转换为完整URL"""
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
return path
|
||||
return self.DEFAULT_BASE_URL.rstrip("/") + "/" + path.lstrip("/")
|
||||
84
loveace/router/endpoint/jwc/model/competition.py
Normal file
84
loveace/router/endpoint/jwc/model/competition.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AwardProject(BaseModel):
|
||||
"""
|
||||
获奖项目信息模型
|
||||
|
||||
表示用户通过创新创业管理平台申报的单个获奖项目
|
||||
"""
|
||||
|
||||
project_id: str = Field("", description="申报ID,唯一标识符")
|
||||
project_name: str = Field("", description="项目名称/赛事名称")
|
||||
level: str = Field("", description="级别(校级/省部级/国家级等)")
|
||||
grade: str = Field("", description="等级/奖项等级(一等奖/二等奖等)")
|
||||
award_date: str = Field("", description="获奖日期,格式为 YYYY/M/D")
|
||||
applicant_id: str = Field("", description="主持人姓名")
|
||||
applicant_name: str = Field("", description="参与人姓名(作为用户)")
|
||||
order: int = Field(0, description="顺序号(多人项目的排序)")
|
||||
credits: float = Field(0.0, description="获奖学分")
|
||||
bonus: float = Field(0.0, description="奖励金额")
|
||||
status: str = Field("", description="申报状态(提交/审核中/已审核等)")
|
||||
verification_status: str = Field(
|
||||
"", description="学校审核状态(通过/未通过/待审核等)"
|
||||
)
|
||||
|
||||
|
||||
class CreditsSummary(BaseModel):
|
||||
"""
|
||||
学分汇总信息模型
|
||||
|
||||
存储用户在创新创业管理平台的各类学分统计
|
||||
"""
|
||||
|
||||
discipline_competition_credits: Optional[float] = Field(
|
||||
None, description="学科竞赛学分"
|
||||
)
|
||||
scientific_research_credits: Optional[float] = Field(
|
||||
None, description="科研项目学分"
|
||||
)
|
||||
transferable_competition_credits: Optional[float] = Field(
|
||||
None, description="可转竞赛类学分"
|
||||
)
|
||||
innovation_practice_credits: Optional[float] = Field(
|
||||
None, description="创新创业实践学分"
|
||||
)
|
||||
ability_certification_credits: Optional[float] = Field(
|
||||
None, description="能力资格认证学分"
|
||||
)
|
||||
other_project_credits: Optional[float] = Field(None, description="其他项目学分")
|
||||
|
||||
|
||||
class CompetitionAwardsResponse(BaseModel):
|
||||
"""
|
||||
获奖项目列表响应模型
|
||||
"""
|
||||
|
||||
student_id: str = Field("", description="学生ID/工号")
|
||||
total_count: int = Field(0, description="获奖项目总数")
|
||||
awards: List[AwardProject] = Field(default_factory=list, description="获奖项目列表")
|
||||
|
||||
|
||||
class CompetitionCreditsSummaryResponse(BaseModel):
|
||||
"""
|
||||
学分汇总响应模型
|
||||
"""
|
||||
|
||||
student_id: str = Field("", description="学生ID/工号")
|
||||
credits_summary: Optional[CreditsSummary] = Field(None, description="学分汇总详情")
|
||||
|
||||
|
||||
class CompetitionFullResponse(BaseModel):
|
||||
"""
|
||||
学科竞赛完整信息响应模型
|
||||
|
||||
整合了获奖项目列表和学分汇总信息,减少网络IO调用
|
||||
在单次请求中返回所有竞赛相关数据
|
||||
"""
|
||||
|
||||
student_id: str = Field("", description="学生ID/工号")
|
||||
total_awards_count: int = Field(0, description="获奖项目总数")
|
||||
awards: List[AwardProject] = Field(default_factory=list, description="获奖项目列表")
|
||||
credits_summary: Optional[CreditsSummary] = Field(None, description="学分汇总详情")
|
||||
65
loveace/router/endpoint/jwc/model/exam.py
Normal file
65
loveace/router/endpoint/jwc/model/exam.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ExamScheduleItem(BaseModel):
|
||||
"""考试安排项目 - 校统考格式"""
|
||||
|
||||
title: str = "" # 考试标题,包含课程名、时间、地点等信息
|
||||
start: str = "" # 考试日期 (YYYY-MM-DD)
|
||||
color: str = "" # 显示颜色
|
||||
|
||||
|
||||
class OtherExamRecord(BaseModel):
|
||||
"""其他考试记录"""
|
||||
|
||||
term_code: str = Field("", alias="ZXJXJHH") # 学期代码
|
||||
term_name: str = Field("", alias="ZXJXJHM") # 学期名称
|
||||
exam_name: str = Field("", alias="KSMC") # 考试名称
|
||||
course_code: str = Field("", alias="KCH") # 课程代码
|
||||
course_name: str = Field("", alias="KCM") # 课程名称
|
||||
class_number: str = Field("", alias="KXH") # 课序号
|
||||
student_id: str = Field("", alias="XH") # 学号
|
||||
student_name: str = Field("", alias="XM") # 姓名
|
||||
exam_location: str = Field("", alias="KSDD") # 考试地点
|
||||
exam_date: str = Field("", alias="KSRQ") # 考试日期
|
||||
exam_time: str = Field("", alias="KSSJ") # 考试时间
|
||||
note: str = Field("", alias="BZ") # 备注
|
||||
row_number: str = Field("", alias="RN") # 行号
|
||||
|
||||
|
||||
class OtherExamResponse(BaseModel):
|
||||
"""其他考试查询响应"""
|
||||
|
||||
page_size: int = Field(0, alias="pageSize")
|
||||
page_num: int = Field(0, alias="pageNum")
|
||||
page_context: Dict[str, int] = Field(default_factory=dict, alias="pageContext")
|
||||
records: Optional[List[OtherExamRecord]] = Field(alias="records")
|
||||
|
||||
|
||||
class UnifiedExamInfo(BaseModel):
|
||||
"""统一考试信息模型 - 对外提供的统一格式"""
|
||||
|
||||
course_name: str = Field("", description="课程名称")
|
||||
exam_date: str = Field("", description="考试日期")
|
||||
exam_time: str = Field("", description="考试时间")
|
||||
exam_location: str = Field("", description="考试地点")
|
||||
exam_type: str = Field("", description="考试类型")
|
||||
note: str = Field("", description="备注")
|
||||
|
||||
|
||||
class ExamInfoResponse(BaseModel):
|
||||
"""考试信息统一响应模型"""
|
||||
|
||||
exams: List[UnifiedExamInfo] = Field(
|
||||
default_factory=list, description="考试信息列表"
|
||||
)
|
||||
total_count: int = Field(0, description="考试总数")
|
||||
|
||||
|
||||
class SeatInfo(BaseModel):
|
||||
"""座位信息模型"""
|
||||
|
||||
course_name: str = Field("", description="课程名称")
|
||||
seat_number: str = Field("", description="座位号")
|
||||
@@ -1,11 +1,12 @@
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PlanCompletionCourse(BaseModel):
|
||||
"""培养方案课程完成情况"""
|
||||
|
||||
|
||||
flag_id: str = Field("", description="课程标识ID")
|
||||
flag_type: str = Field("", description="节点类型:001=分类, 002=子分类, kch=课程")
|
||||
course_code: str = Field("", description="课程代码,如 PDA2121005")
|
||||
@@ -18,7 +19,7 @@ class PlanCompletionCourse(BaseModel):
|
||||
course_type: str = Field("", description="课程类型:必修/任选等")
|
||||
parent_id: str = Field("", description="父节点ID")
|
||||
level: int = Field(0, description="层级:0=根分类,1=子分类,2=课程")
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_ztree_node(cls, node: dict) -> "PlanCompletionCourse":
|
||||
"""从 zTree 节点数据创建课程对象"""
|
||||
@@ -27,11 +28,11 @@ class PlanCompletionCourse(BaseModel):
|
||||
flag_id = node.get("flagId", "")
|
||||
flag_type = node.get("flagType", "")
|
||||
parent_id = node.get("pId", "")
|
||||
|
||||
|
||||
# 根据CSS图标判断通过状态
|
||||
is_passed = False
|
||||
status_description = "未修读"
|
||||
|
||||
|
||||
if "fa-smile-o fa-1x green" in name:
|
||||
is_passed = True
|
||||
status_description = "已通过"
|
||||
@@ -41,12 +42,12 @@ class PlanCompletionCourse(BaseModel):
|
||||
elif "fa-frown-o fa-1x red" in name:
|
||||
is_passed = False
|
||||
status_description = "未通过"
|
||||
|
||||
|
||||
# 从name中提取纯文本内容
|
||||
# 移除HTML标签和图标
|
||||
clean_name = re.sub(r'<[^>]*>', '', name)
|
||||
clean_name = re.sub(r' ', ' ', clean_name).strip()
|
||||
|
||||
clean_name = re.sub(r"<[^>]*>", "", name)
|
||||
clean_name = re.sub(r" ", " ", clean_name).strip()
|
||||
|
||||
# 解析课程信息
|
||||
course_code = ""
|
||||
course_name = ""
|
||||
@@ -54,116 +55,142 @@ class PlanCompletionCourse(BaseModel):
|
||||
score = None
|
||||
exam_date = None
|
||||
course_type = ""
|
||||
|
||||
|
||||
if flag_type == "kch": # 课程节点
|
||||
# 解析课程代码:[PDA2121005]形势与政策
|
||||
code_match = re.search(r'\[([^\]]+)\]', clean_name)
|
||||
code_match = re.search(r"\[([^\]]+)\]", clean_name)
|
||||
if code_match:
|
||||
course_code = code_match.group(1)
|
||||
remaining_text = clean_name.split(']', 1)[1].strip()
|
||||
|
||||
remaining_text = clean_name.split("]", 1)[1].strip()
|
||||
|
||||
# 解析学分信息:[0.3学分]
|
||||
credit_match = re.search(r'\[([0-9.]+)学分\]', remaining_text)
|
||||
credit_match = re.search(r"\[([0-9.]+)学分\]", remaining_text)
|
||||
if credit_match:
|
||||
credits = float(credit_match.group(1))
|
||||
remaining_text = re.sub(r'\[[0-9.]+学分\]', '', remaining_text).strip()
|
||||
|
||||
remaining_text = re.sub(
|
||||
r"\[[0-9.]+学分\]", "", remaining_text
|
||||
).strip()
|
||||
|
||||
# 处理复杂的括号内容
|
||||
# 例如:85.0(20250626 成绩,都没把日期解析上,中国近现代史纲要)
|
||||
# 或者:(任选,87.0(20250119))
|
||||
|
||||
|
||||
# 找到最外层的括号
|
||||
paren_match = re.search(r'\(([^)]+(?:\([^)]*\)[^)]*)*)\)$', remaining_text)
|
||||
paren_match = re.search(
|
||||
r"\(([^)]+(?:\([^)]*\)[^)]*)*)\)$", remaining_text
|
||||
)
|
||||
if paren_match:
|
||||
paren_content = paren_match.group(1)
|
||||
course_name_candidate = re.sub(r'\([^)]+(?:\([^)]*\)[^)]*)*\)$', '', remaining_text).strip()
|
||||
|
||||
course_name_candidate = re.sub(
|
||||
r"\([^)]+(?:\([^)]*\)[^)]*)*\)$", "", remaining_text
|
||||
).strip()
|
||||
|
||||
# 检查括号内容的格式
|
||||
if ',' in paren_content:
|
||||
if "," in paren_content:
|
||||
# 处理包含中文逗号的复杂格式
|
||||
parts = paren_content.split(',')
|
||||
|
||||
parts = paren_content.split(",")
|
||||
|
||||
# 最后一部分可能是课程名
|
||||
last_part = parts[-1].strip()
|
||||
if re.search(r'[\u4e00-\u9fff]', last_part) and len(last_part) > 1:
|
||||
if (
|
||||
re.search(r"[\u4e00-\u9fff]", last_part)
|
||||
and len(last_part) > 1
|
||||
):
|
||||
# 最后一部分包含中文,很可能是真正的课程名
|
||||
course_name = last_part
|
||||
|
||||
|
||||
# 从前面的部分提取成绩和其他信息
|
||||
remaining_parts = ','.join(parts[:-1])
|
||||
|
||||
remaining_parts = ",".join(parts[:-1])
|
||||
|
||||
# 提取成绩
|
||||
score_match = re.search(r'([0-9.]+)', remaining_parts)
|
||||
score_match = re.search(r"([0-9.]+)", remaining_parts)
|
||||
if score_match:
|
||||
score = score_match.group(1)
|
||||
|
||||
|
||||
# 提取日期
|
||||
date_match = re.search(r'(\d{8})', remaining_parts)
|
||||
date_match = re.search(r"(\d{8})", remaining_parts)
|
||||
if date_match:
|
||||
exam_date = date_match.group(1)
|
||||
|
||||
|
||||
# 提取课程类型(如果有的话)
|
||||
if len(parts) > 2:
|
||||
potential_type = parts[0].strip()
|
||||
if not re.search(r'[0-9.]', potential_type):
|
||||
if not re.search(r"[0-9.]", potential_type):
|
||||
course_type = potential_type
|
||||
else:
|
||||
# 最后一部分不是课程名,使用括号外的内容
|
||||
course_name = course_name_candidate if course_name_candidate else "未知课程"
|
||||
|
||||
course_name = (
|
||||
course_name_candidate
|
||||
if course_name_candidate
|
||||
else "未知课程"
|
||||
)
|
||||
|
||||
# 从整个括号内容提取信息
|
||||
score_match = re.search(r'([0-9.]+)', paren_content)
|
||||
score_match = re.search(r"([0-9.]+)", paren_content)
|
||||
if score_match:
|
||||
score = score_match.group(1)
|
||||
|
||||
date_match = re.search(r'(\d{8})', paren_content)
|
||||
|
||||
date_match = re.search(r"(\d{8})", paren_content)
|
||||
if date_match:
|
||||
exam_date = date_match.group(1)
|
||||
|
||||
elif ',' in paren_content:
|
||||
|
||||
elif "," in paren_content:
|
||||
# 处理标准格式:(任选,87.0(20250119))
|
||||
type_score_parts = paren_content.split(',', 1)
|
||||
type_score_parts = paren_content.split(",", 1)
|
||||
if len(type_score_parts) == 2:
|
||||
course_type = type_score_parts[0].strip()
|
||||
score_info = type_score_parts[1].strip()
|
||||
|
||||
|
||||
# 解析成绩和日期
|
||||
score_date_match = re.search(r'([0-9.]+)\((\d{8})\)', score_info)
|
||||
score_date_match = re.search(
|
||||
r"([0-9.]+)\((\d{8})\)", score_info
|
||||
)
|
||||
if score_date_match:
|
||||
score = score_date_match.group(1)
|
||||
exam_date = score_date_match.group(2)
|
||||
else:
|
||||
score_match = re.search(r'([0-9.]+)', score_info)
|
||||
score_match = re.search(r"([0-9.]+)", score_info)
|
||||
if score_match:
|
||||
score = score_match.group(1)
|
||||
|
||||
|
||||
# 使用括号外的内容作为课程名
|
||||
course_name = course_name_candidate if course_name_candidate else "未知课程"
|
||||
|
||||
course_name = (
|
||||
course_name_candidate
|
||||
if course_name_candidate
|
||||
else "未知课程"
|
||||
)
|
||||
|
||||
else:
|
||||
# 括号内只有简单内容
|
||||
course_name = course_name_candidate if course_name_candidate else "未知课程"
|
||||
|
||||
course_name = (
|
||||
course_name_candidate
|
||||
if course_name_candidate
|
||||
else "未知课程"
|
||||
)
|
||||
|
||||
# 尝试从括号内容提取成绩
|
||||
score_match = re.search(r'([0-9.]+)', paren_content)
|
||||
score_match = re.search(r"([0-9.]+)", paren_content)
|
||||
if score_match:
|
||||
score = score_match.group(1)
|
||||
|
||||
|
||||
# 尝试提取日期
|
||||
date_match = re.search(r'(\d{8})', paren_content)
|
||||
date_match = re.search(r"(\d{8})", paren_content)
|
||||
if date_match:
|
||||
exam_date = date_match.group(1)
|
||||
else:
|
||||
# 没有括号,直接使用剩余文本作为课程名
|
||||
course_name = remaining_text
|
||||
|
||||
|
||||
# 清理课程名
|
||||
course_name = re.sub(r'\s+', ' ', course_name).strip()
|
||||
course_name = course_name.strip(',,。.')
|
||||
|
||||
course_name = re.sub(r"\s+", " ", course_name).strip()
|
||||
course_name = course_name.strip(",,。.")
|
||||
|
||||
# 如果课程名为空或太短,尝试从原始名称提取
|
||||
if not course_name or len(course_name) < 2:
|
||||
chinese_match = re.search(r'[\u4e00-\u9fff]+(?:[\u4e00-\u9fff\s]*[\u4e00-\u9fff]+)*', clean_name)
|
||||
chinese_match = re.search(
|
||||
r"[\u4e00-\u9fff]+(?:[\u4e00-\u9fff\s]*[\u4e00-\u9fff]+)*",
|
||||
clean_name,
|
||||
)
|
||||
if chinese_match:
|
||||
course_name = chinese_match.group(0).strip()
|
||||
else:
|
||||
@@ -171,26 +198,28 @@ class PlanCompletionCourse(BaseModel):
|
||||
else:
|
||||
# 分类节点
|
||||
course_name = clean_name
|
||||
|
||||
|
||||
# 清理分类名称中的多余括号,但保留重要信息
|
||||
# 如果是包含学分信息的分类名,保留学分信息
|
||||
if not re.search(r'学分', course_name):
|
||||
if not re.search(r"学分", course_name):
|
||||
# 删除分类名称中的统计信息括号,如 "通识通修(已完成20.0/需要20.0)"
|
||||
course_name = re.sub(r'\([^)]*完成[^)]*\)', '', course_name).strip()
|
||||
course_name = re.sub(r"\([^)]*完成[^)]*\)", "", course_name).strip()
|
||||
# 删除其他可能的统计括号
|
||||
course_name = re.sub(r'\([^)]*\d+\.\d+/[^)]*\)', '', course_name).strip()
|
||||
|
||||
course_name = re.sub(
|
||||
r"\([^)]*\d+\.\d+/[^)]*\)", "", course_name
|
||||
).strip()
|
||||
|
||||
# 清理多余的空格和空括号
|
||||
course_name = re.sub(r'\(\s*\)', '', course_name).strip()
|
||||
course_name = re.sub(r'\s+', ' ', course_name).strip()
|
||||
|
||||
course_name = re.sub(r"\(\s*\)", "", course_name).strip()
|
||||
course_name = re.sub(r"\s+", " ", course_name).strip()
|
||||
|
||||
# 确定层级
|
||||
level = 0
|
||||
if flag_type == "002":
|
||||
level = 1
|
||||
elif flag_type == "kch":
|
||||
level = 2
|
||||
|
||||
|
||||
return cls(
|
||||
flag_id=flag_id,
|
||||
flag_type=flag_type,
|
||||
@@ -203,13 +232,13 @@ class PlanCompletionCourse(BaseModel):
|
||||
exam_date=exam_date,
|
||||
course_type=course_type,
|
||||
parent_id=parent_id,
|
||||
level=level
|
||||
level=level,
|
||||
)
|
||||
|
||||
|
||||
class PlanCompletionCategory(BaseModel):
|
||||
"""培养方案分类完成情况"""
|
||||
|
||||
|
||||
category_id: str = Field("", description="分类ID")
|
||||
category_name: str = Field("", description="分类名称")
|
||||
min_credits: float = Field(0.0, description="最低修读学分")
|
||||
@@ -218,26 +247,30 @@ class PlanCompletionCategory(BaseModel):
|
||||
passed_courses: int = Field(0, description="已及格课程门数")
|
||||
failed_courses: int = Field(0, description="未及格课程门数")
|
||||
missing_required_courses: int = Field(0, description="必修课缺修门数")
|
||||
subcategories: List["PlanCompletionCategory"] = Field(default_factory=list, description="子分类")
|
||||
courses: List[PlanCompletionCourse] = Field(default_factory=list, description="课程列表")
|
||||
|
||||
subcategories: List["PlanCompletionCategory"] = Field(
|
||||
default_factory=list, description="子分类"
|
||||
)
|
||||
courses: List[PlanCompletionCourse] = Field(
|
||||
default_factory=list, description="课程列表"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_ztree_node(cls, node: dict) -> "PlanCompletionCategory":
|
||||
"""从 zTree 节点创建分类对象"""
|
||||
name = node.get("name", "")
|
||||
flag_id = node.get("flagId", "")
|
||||
|
||||
|
||||
# 移除HTML标签获取纯文本
|
||||
clean_name = re.sub(r'<[^>]*>', '', name)
|
||||
clean_name = re.sub(r' ', ' ', clean_name).strip()
|
||||
|
||||
clean_name = re.sub(r"<[^>]*>", "", name)
|
||||
clean_name = re.sub(r" ", " ", clean_name).strip()
|
||||
|
||||
# 解析分类统计信息
|
||||
# 格式:通识通修(最低修读学分:68,通过学分:34.4,已修课程门数:26,已及格课程门数:26,未及格课程门数:0,必修课缺修门数:12)
|
||||
stats_match = re.search(
|
||||
r'([^(]+)\(最低修读学分:([0-9.]+),通过学分:([0-9.]+),已修课程门数:(\d+),已及格课程门数:(\d+),未及格课程门数:(\d+),必修课缺修门数:(\d+)\)',
|
||||
clean_name
|
||||
r"([^(]+)\(最低修读学分:([0-9.]+),通过学分:([0-9.]+),已修课程门数:(\d+),已及格课程门数:(\d+),未及格课程门数:(\d+),必修课缺修门数:(\d+)\)",
|
||||
clean_name,
|
||||
)
|
||||
|
||||
|
||||
if stats_match:
|
||||
category_name = stats_match.group(1)
|
||||
min_credits = float(stats_match.group(2))
|
||||
@@ -255,7 +288,7 @@ class PlanCompletionCategory(BaseModel):
|
||||
passed_courses = 0
|
||||
failed_courses = 0
|
||||
missing_required_courses = 0
|
||||
|
||||
|
||||
return cls(
|
||||
category_id=flag_id,
|
||||
category_name=category_name,
|
||||
@@ -264,33 +297,35 @@ class PlanCompletionCategory(BaseModel):
|
||||
total_courses=total_courses,
|
||||
passed_courses=passed_courses,
|
||||
failed_courses=failed_courses,
|
||||
missing_required_courses=missing_required_courses
|
||||
missing_required_courses=missing_required_courses,
|
||||
)
|
||||
|
||||
|
||||
class PlanCompletionInfo(BaseModel):
|
||||
"""培养方案完成情况总信息"""
|
||||
|
||||
|
||||
plan_name: str = Field("", description="培养方案名称")
|
||||
major: str = Field("", description="专业名称")
|
||||
grade: str = Field("", description="年级")
|
||||
categories: List[PlanCompletionCategory] = Field(default_factory=list, description="分类列表")
|
||||
categories: List[PlanCompletionCategory] = Field(
|
||||
default_factory=list, description="分类列表"
|
||||
)
|
||||
total_categories: int = Field(0, description="总分类数")
|
||||
total_courses: int = Field(0, description="总课程数")
|
||||
passed_courses: int = Field(0, description="已通过课程数")
|
||||
failed_courses: int = Field(0, description="未通过课程数")
|
||||
unread_courses: int = Field(0, description="未修读课程数")
|
||||
|
||||
|
||||
def calculate_statistics(self):
|
||||
"""计算统计信息"""
|
||||
total_courses = 0
|
||||
passed_courses = 0
|
||||
failed_courses = 0
|
||||
unread_courses = 0
|
||||
|
||||
|
||||
def count_courses(categories: List[PlanCompletionCategory]):
|
||||
nonlocal total_courses, passed_courses, failed_courses, unread_courses
|
||||
|
||||
|
||||
for category in categories:
|
||||
for course in category.courses:
|
||||
total_courses += 1
|
||||
@@ -300,43 +335,14 @@ class PlanCompletionInfo(BaseModel):
|
||||
failed_courses += 1
|
||||
else:
|
||||
unread_courses += 1
|
||||
|
||||
|
||||
# 递归处理子分类
|
||||
count_courses(category.subcategories)
|
||||
|
||||
|
||||
count_courses(self.categories)
|
||||
|
||||
|
||||
self.total_categories = len(self.categories)
|
||||
self.total_courses = total_courses
|
||||
self.passed_courses = passed_courses
|
||||
self.failed_courses = failed_courses
|
||||
self.unread_courses = unread_courses
|
||||
|
||||
|
||||
class PlanCompletionResponse(BaseModel):
|
||||
"""培养方案完成情况响应"""
|
||||
|
||||
code: int = Field(0, description="响应码")
|
||||
message: str = Field("success", description="响应消息")
|
||||
data: Optional[PlanCompletionInfo] = Field(None, description="培养方案完成情况数据")
|
||||
|
||||
|
||||
class ErrorPlanCompletionInfo(PlanCompletionInfo):
|
||||
"""错误的培养方案完成情况信息"""
|
||||
|
||||
plan_name: str = Field("请求失败", description="培养方案名称")
|
||||
major: str = Field("请求失败", description="专业名称")
|
||||
grade: str = Field("", description="年级")
|
||||
total_categories: int = Field(-1, description="总分类数")
|
||||
total_courses: int = Field(-1, description="总课程数")
|
||||
passed_courses: int = Field(-1, description="已通过课程数")
|
||||
failed_courses: int = Field(-1, description="未通过课程数")
|
||||
unread_courses: int = Field(-1, description="未修读课程数")
|
||||
|
||||
|
||||
class ErrorPlanCompletionResponse(BaseModel):
|
||||
"""错误的培养方案完成情况响应"""
|
||||
|
||||
code: int = Field(-1, description="响应码")
|
||||
message: str = Field("请求失败,请稍后重试", description="响应消息")
|
||||
data: Optional[ErrorPlanCompletionInfo] = Field(default=None, description="错误数据")
|
||||
@@ -1,71 +1,11 @@
|
||||
from router.common_model import BaseResponse
|
||||
from provider.aufe.jwc.model import (
|
||||
AcademicInfo,
|
||||
TrainingPlanInfo,
|
||||
Course,
|
||||
ExamInfoResponse,
|
||||
TermScoreResponse,
|
||||
)
|
||||
from typing import List, Dict
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# 统一响应模型
|
||||
class AcademicInfoResponse(BaseResponse[AcademicInfo]):
|
||||
"""学业信息响应"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TrainingPlanInfoResponse(BaseResponse[TrainingPlanInfo]):
|
||||
"""培养方案信息响应"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CourseListResponse(BaseResponse[List[Course]]):
|
||||
"""评教课程列表响应"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ExamInfoAPIResponse(BaseResponse[ExamInfoResponse]):
|
||||
"""考试信息响应"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ==================== 学期和成绩相关响应模型 ====================
|
||||
|
||||
|
||||
class FetchTermScoreRequest(BaseModel):
|
||||
"""获取学期成绩请求模型"""
|
||||
|
||||
term_id: str = Field(..., description="学期ID,如:2024-2025-2-1")
|
||||
course_code: str = Field("", description="课程代码(可选,用于筛选)")
|
||||
course_name: str = Field("", description="课程名称(可选,用于筛选)")
|
||||
page_num: int = Field(1, description="页码,默认为1", ge=1)
|
||||
page_size: int = Field(50, description="每页大小,默认为50", ge=1, le=100)
|
||||
|
||||
|
||||
class AllTermsResponse(BaseResponse[Dict[str, str]]):
|
||||
"""所有学期信息响应"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TermScoreAPIResponse(BaseResponse[TermScoreResponse]):
|
||||
"""学期成绩响应"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ==================== 课表相关响应模型 ====================
|
||||
|
||||
|
||||
class TimeSlot(BaseModel):
|
||||
"""时间段模型"""
|
||||
|
||||
|
||||
session: int = Field(..., description="节次")
|
||||
session_name: str = Field(..., description="节次名称")
|
||||
start_time: str = Field(..., description="开始时间,格式:HHMM")
|
||||
@@ -76,7 +16,7 @@ class TimeSlot(BaseModel):
|
||||
|
||||
class CourseTimeLocation(BaseModel):
|
||||
"""课程时间地点模型"""
|
||||
|
||||
|
||||
class_day: int = Field(..., description="上课星期几(1-7)")
|
||||
class_sessions: int = Field(..., description="上课节次")
|
||||
continuing_session: int = Field(..., description="持续节次数")
|
||||
@@ -89,7 +29,7 @@ class CourseTimeLocation(BaseModel):
|
||||
|
||||
class ScheduleCourse(BaseModel):
|
||||
"""课表课程模型"""
|
||||
|
||||
|
||||
course_name: str = Field(..., description="课程名称")
|
||||
course_code: str = Field(..., description="课程代码")
|
||||
course_sequence: str = Field(..., description="课程序号")
|
||||
@@ -103,20 +43,7 @@ class ScheduleCourse(BaseModel):
|
||||
|
||||
class ScheduleData(BaseModel):
|
||||
"""课表数据模型"""
|
||||
|
||||
|
||||
total_units: float = Field(..., description="总学分")
|
||||
time_slots: List[TimeSlot] = Field(..., description="时间段列表")
|
||||
courses: List[ScheduleCourse] = Field(..., description="课程列表")
|
||||
semester_info: Dict[str, str] = Field(..., description="学期信息")
|
||||
|
||||
|
||||
class ScheduleResponse(BaseResponse[ScheduleData]):
|
||||
"""课表响应模型"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class FetchScheduleRequest(BaseModel):
|
||||
"""获取课表请求模型"""
|
||||
|
||||
plan_code: str = Field(..., description="培养方案代码,如:2024-2025-2-1")
|
||||
28
loveace/router/endpoint/jwc/model/score.py
Normal file
28
loveace/router/endpoint/jwc/model/score.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ScoreRecord(BaseModel):
|
||||
"""成绩记录模型"""
|
||||
|
||||
sequence: int = Field(0, description="序号")
|
||||
term_id: str = Field("", description="学期ID")
|
||||
course_code: str = Field("", description="课程代码")
|
||||
course_class: str = Field("", description="课程班级")
|
||||
course_name_cn: str = Field("", description="课程名称(中文)")
|
||||
course_name_en: str = Field("", description="课程名称(英文)")
|
||||
credits: str = Field("", description="学分")
|
||||
hours: int = Field(0, description="学时")
|
||||
course_type: Optional[str] = Field(None, description="课程性质")
|
||||
exam_type: Optional[str] = Field(None, description="考试性质")
|
||||
score: str = Field("", description="成绩")
|
||||
retake_score: Optional[str] = Field(None, description="重修成绩")
|
||||
makeup_score: Optional[str] = Field(None, description="补考成绩")
|
||||
|
||||
|
||||
class TermScoreResponse(BaseModel):
|
||||
"""学期成绩响应模型"""
|
||||
|
||||
total_count: int = Field(0, description="总记录数")
|
||||
records: List[ScoreRecord] = Field(default_factory=list, description="成绩记录列表")
|
||||
20
loveace/router/endpoint/jwc/model/term.py
Normal file
20
loveace/router/endpoint/jwc/model/term.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TermItem(BaseModel):
|
||||
"""学期信息项"""
|
||||
|
||||
term_code: str = Field(..., description="学期代码")
|
||||
term_name: str = Field(..., description="学期名称")
|
||||
is_current: bool = Field(..., description="是否为当前学期")
|
||||
|
||||
|
||||
class CurrentTermInfo(BaseModel):
|
||||
"""学期周数信息"""
|
||||
|
||||
academic_year: str = Field("", description="学年,如 2025-2026")
|
||||
current_term_name: str = Field("", description="学期,如 秋、春")
|
||||
week_number: int = Field(0, description="当前周数")
|
||||
start_at: str = Field("", description="学期开始时间,格式 YYYY-MM-DD")
|
||||
is_end: bool = Field(False, description="是否为学期结束")
|
||||
weekday: int = Field(0, description="星期几")
|
||||
262
loveace/router/endpoint/jwc/plan.py
Normal file
262
loveace/router/endpoint/jwc/plan.py
Normal file
@@ -0,0 +1,262 @@
|
||||
import re
|
||||
|
||||
import ujson
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from httpx import HTTPError
|
||||
from pydantic import ValidationError
|
||||
from ujson import JSONDecodeError
|
||||
|
||||
from loveace.router.endpoint.jwc.model.base import JWCConfig
|
||||
from loveace.router.endpoint.jwc.model.plan import (
|
||||
PlanCompletionCategory,
|
||||
PlanCompletionInfo,
|
||||
)
|
||||
from loveace.router.endpoint.jwc.utils.plan import populate_category_children
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
ENDPOINT = {
|
||||
"plan": "/student/integratedQuery/planCompletion/index",
|
||||
}
|
||||
|
||||
jwc_plan_router = APIRouter(
|
||||
prefix="/plan",
|
||||
responses=ProtectRouterErrorToCode().gen_code_table(),
|
||||
)
|
||||
|
||||
|
||||
@jwc_plan_router.get(
|
||||
"/current",
|
||||
summary="获取当前培养方案完成信息",
|
||||
response_model=UniResponseModel[PlanCompletionInfo],
|
||||
)
|
||||
async def get_current_plan_completion(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[PlanCompletionInfo] | JSONResponse:
|
||||
"""
|
||||
获取用户的培养方案完成情况
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取培养方案的总体完成进度
|
||||
- 按类别显示各类课程的完成情况
|
||||
- 显示已完成、未完成、可选课程等
|
||||
|
||||
💡 使用场景:
|
||||
- 查看毕业要求的完成进度
|
||||
- 了解还需要修读哪些课程
|
||||
- 规划后续选课
|
||||
|
||||
Returns:
|
||||
PlanCompletionInfo: 包含方案完成情况和各类别详情
|
||||
"""
|
||||
try:
|
||||
conn.logger.info("获取当前培养方案完成信息")
|
||||
response = await conn.client.get(
|
||||
JWCConfig().to_full_url(ENDPOINT["plan"]),
|
||||
follow_redirects=True,
|
||||
timeout=600,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(f"获取培养方案信息失败,状态码: {response.status_code}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
html_content = response.text
|
||||
|
||||
# 使用BeautifulSoup解析HTML
|
||||
soup = BeautifulSoup(html_content, "lxml")
|
||||
|
||||
# 提取培养方案名称
|
||||
plan_name = ""
|
||||
|
||||
# 查找包含"培养方案"的h4标签
|
||||
h4_elements = soup.find_all("h4")
|
||||
for h4 in h4_elements:
|
||||
text = h4.get_text(strip=True) if h4 else ""
|
||||
if "培养方案" in text:
|
||||
plan_name = text
|
||||
conn.logger.info(f"找到培养方案标题: {plan_name}")
|
||||
break
|
||||
|
||||
# 解析专业和年级信息
|
||||
major = ""
|
||||
grade = ""
|
||||
if plan_name:
|
||||
grade_match = re.search(r"(\d{4})级", plan_name)
|
||||
if grade_match:
|
||||
grade = grade_match.group(1)
|
||||
|
||||
major_match = re.search(r"\d{4}级(.+?)本科", plan_name)
|
||||
if major_match:
|
||||
major = major_match.group(1)
|
||||
|
||||
# 查找zTree数据
|
||||
ztree_data = []
|
||||
|
||||
# 在script标签中查找zTree初始化数据
|
||||
scripts = soup.find_all("script")
|
||||
for script in scripts:
|
||||
try:
|
||||
script_text = script.get_text() if script else ""
|
||||
if "$.fn.zTree.init" in script_text and "flagId" in script_text:
|
||||
conn.logger.info("找到包含zTree初始化的script标签")
|
||||
|
||||
# 提取zTree数据
|
||||
# 尝试多种模式匹配
|
||||
patterns = [
|
||||
r'\$\.fn\.zTree\.init\(\$\("#treeDemo"\),\s*setting,\s*(\[.*?\])\s*\);',
|
||||
r"\.zTree\.init\([^,]+,\s*[^,]+,\s*(\[.*?\])\s*\);",
|
||||
r'init\(\$\("#treeDemo"\)[^,]*,\s*[^,]*,\s*(\[.*?\])',
|
||||
]
|
||||
|
||||
json_part = None
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, script_text, re.DOTALL)
|
||||
if match:
|
||||
json_part = match.group(1)
|
||||
conn.logger.info(
|
||||
f"使用模式匹配成功提取zTree数据: {len(json_part)}字符"
|
||||
)
|
||||
break
|
||||
|
||||
if json_part:
|
||||
# 清理和修复JSON格式
|
||||
# 移除JavaScript注释和多余的逗号
|
||||
json_part = re.sub(r"//.*?\n", "\n", json_part)
|
||||
json_part = re.sub(r"/\*.*?\*/", "", json_part, flags=re.DOTALL)
|
||||
json_part = re.sub(r",\s*}", "}", json_part)
|
||||
json_part = re.sub(r",\s*]", "]", json_part)
|
||||
|
||||
try:
|
||||
ztree_data = ujson.loads(json_part)
|
||||
conn.logger.info(f"JSON解析成功,共{len(ztree_data)}个节点")
|
||||
break
|
||||
except JSONDecodeError as e:
|
||||
conn.logger.warning(f"JSON解析失败: {str(e)}")
|
||||
# 如果JSON解析失败,不使用手动解析,直接跳过
|
||||
continue
|
||||
else:
|
||||
conn.logger.warning("未能通过模式匹配提取zTree数据")
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
if not ztree_data:
|
||||
conn.logger.warning("未找到有效的zTree数据")
|
||||
|
||||
# 输出调试信息
|
||||
conn.logger.info(f"HTML内容长度: {len(html_content)}")
|
||||
conn.logger.info(f"找到的script标签数量: {len(soup.find_all('script'))}")
|
||||
|
||||
# 检查是否包含关键词
|
||||
contains_ztree = "zTree" in html_content
|
||||
contains_flagid = "flagId" in html_content
|
||||
contains_plan = "培养方案" in html_content
|
||||
conn.logger.info(
|
||||
f"HTML包含关键词: zTree={contains_ztree}, flagId={contains_flagid}, 培养方案={contains_plan}"
|
||||
)
|
||||
conn.logger.warning("未找到有效的zTree数据")
|
||||
|
||||
if contains_plan:
|
||||
conn.logger.warning(
|
||||
"检测到培养方案内容,但zTree数据解析失败,可能页面结构已变化"
|
||||
)
|
||||
else:
|
||||
conn.logger.warning(
|
||||
"未检测到培养方案相关内容,可能需要重新登录或检查访问权限"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id,
|
||||
message="未找到有效的培养方案数据,请检查登录状态或稍后再试",
|
||||
)
|
||||
# 解析zTree数据构建分类和课程信息
|
||||
try:
|
||||
# 按层级组织数据
|
||||
nodes_by_id = {node["id"]: node for node in ztree_data}
|
||||
root_categories = []
|
||||
|
||||
# 统计根分类和所有节点信息,用于调试
|
||||
all_parent_ids = set()
|
||||
root_nodes = []
|
||||
|
||||
for node in ztree_data:
|
||||
parent_id = node.get("pId", "")
|
||||
all_parent_ids.add(parent_id)
|
||||
|
||||
# 根分类的判断条件:pId为"-1"(这是zTree中真正的根节点标识)
|
||||
# 从HTML示例可以看出,真正的根分类的pId是"-1"
|
||||
is_root_category = parent_id == "-1"
|
||||
|
||||
if is_root_category:
|
||||
root_nodes.append(node)
|
||||
|
||||
conn.logger.info(
|
||||
f"zTree数据分析: 总节点数={len(ztree_data)}, 根节点数={len(root_nodes)}, 不同父ID数={len(all_parent_ids)}"
|
||||
)
|
||||
conn.logger.debug(f"所有父ID: {sorted(all_parent_ids)}")
|
||||
|
||||
# 构建分类树
|
||||
for node in root_nodes:
|
||||
category = PlanCompletionCategory.from_ztree_node(node)
|
||||
# 填充分类的子分类和课程(支持多层嵌套)
|
||||
try:
|
||||
populate_category_children(category, node["id"], nodes_by_id, conn)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"填充分类子项异常: {str(e)}")
|
||||
conn.logger.error(
|
||||
f"异常节点信息: category_id={node['id']}, 错误详情: {str(e)}"
|
||||
)
|
||||
root_categories.append(category)
|
||||
conn.logger.info(
|
||||
f"创建根分类: {category.category_name} (ID: {node['id']})"
|
||||
)
|
||||
|
||||
# 创建完成情况信息
|
||||
completion_info = PlanCompletionInfo(
|
||||
plan_name=plan_name,
|
||||
major=major,
|
||||
grade=grade,
|
||||
categories=root_categories,
|
||||
total_categories=0,
|
||||
total_courses=0,
|
||||
passed_courses=0,
|
||||
failed_courses=0,
|
||||
unread_courses=0,
|
||||
)
|
||||
|
||||
# 计算统计信息
|
||||
completion_info.calculate_statistics()
|
||||
conn.logger.info(
|
||||
f"培养方案完成信息统计: 分类数={completion_info.total_categories}, 课程数={completion_info.total_courses}, 已过课程={completion_info.passed_courses}, 未过课程={completion_info.failed_courses}, 未修读课程={completion_info.unread_courses}"
|
||||
)
|
||||
return UniResponseModel[PlanCompletionInfo](
|
||||
success=True,
|
||||
data=completion_info,
|
||||
message="获取培养方案完成信息成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"数据验证错误: {ve}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"HTTP请求错误: {he}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
246
loveace/router/endpoint/jwc/schedule.py
Normal file
246
loveace/router/endpoint/jwc/schedule.py
Normal file
@@ -0,0 +1,246 @@
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from loveace.router.endpoint.jwc.model.base import JWCConfig
|
||||
from loveace.router.endpoint.jwc.model.schedule import ScheduleData
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
jwc_schedules_router = APIRouter(
|
||||
prefix="/schedule",
|
||||
responses=ProtectRouterErrorToCode.gen_code_table(),
|
||||
)
|
||||
|
||||
|
||||
ENDPOINTS = {
|
||||
"student_schedule_pre": "/student/courseSelect/calendarSemesterCurriculum/index",
|
||||
"student_schedule": "/student/courseSelect/thisSemesterCurriculum/{dynamic_path}/ajaxStudentSchedule/past/callback",
|
||||
"section_and_time": "/ajax/getSectionAndTime",
|
||||
}
|
||||
|
||||
|
||||
@jwc_schedules_router.get(
|
||||
"/{term_code}/table",
|
||||
summary="获取课表信息",
|
||||
response_model=UniResponseModel[ScheduleData],
|
||||
)
|
||||
async def get_schedule_table(
|
||||
term_code: str, conn: AUFEConnection = Depends(get_aufe_conn)
|
||||
) -> UniResponseModel[ScheduleData] | JSONResponse:
|
||||
"""
|
||||
获取指定学期的课程表
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取指定学期的完整课程表
|
||||
- 显示课程名称、教室、时间、教师等信息
|
||||
- 支持按周查询
|
||||
|
||||
💡 使用场景:
|
||||
- 查看本周课程安排
|
||||
- 了解完整学期课程表
|
||||
- 课表分享和导出
|
||||
|
||||
Args:
|
||||
term_code: 学期代码(如:2023-2024-1)
|
||||
|
||||
Returns:
|
||||
ScheduleData: 包含课程表数据和课程详情
|
||||
"""
|
||||
try:
|
||||
conn.logger.info(f"开始获取学期 {term_code} 的课表信息")
|
||||
# 第一步:访问课表预备页面,获取动态路径
|
||||
|
||||
dynamic_page = JWCConfig().to_full_url(ENDPOINTS["student_schedule_pre"])
|
||||
dynamic_page_response = await conn.client.get(
|
||||
dynamic_page, follow_redirects=True, timeout=conn.timeout
|
||||
)
|
||||
if dynamic_page_response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取课表预备页面失败,状态码: {dynamic_page_response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode.remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
soup = BeautifulSoup(dynamic_page_response.text, "lxml")
|
||||
|
||||
# 尝试从页面中提取动态路径
|
||||
scripts = soup.find_all("script")
|
||||
dynamic_path = "B2RMNJkT95" # 默认值
|
||||
for script in scripts:
|
||||
try:
|
||||
script_text = script.string # type: ignore
|
||||
if script_text and "ajaxStudentSchedule" in script_text:
|
||||
# 使用正则表达式提取路径
|
||||
match = re.search(
|
||||
r"/([A-Za-z0-9]+)/ajaxStudentSchedule", script_text
|
||||
)
|
||||
if match:
|
||||
dynamic_path = match.group(1)
|
||||
break
|
||||
except AttributeError:
|
||||
continue
|
||||
section_and_time_headers = {
|
||||
**conn.client.headers,
|
||||
"Referer": JWCConfig().to_full_url(ENDPOINTS["student_schedule"]),
|
||||
}
|
||||
select_and_time_url = JWCConfig().to_full_url(ENDPOINTS["section_and_time"])
|
||||
select_and_time_data = {
|
||||
"planNumber": "",
|
||||
"ff": "f",
|
||||
"sf_request_type": "ajax",
|
||||
}
|
||||
section_and_time_response_coro = conn.client.post(
|
||||
select_and_time_url,
|
||||
data=select_and_time_data,
|
||||
headers=section_and_time_headers,
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
student_schedule_url = JWCConfig().to_full_url(
|
||||
ENDPOINTS["student_schedule"].format(dynamic_path=dynamic_path)
|
||||
)
|
||||
|
||||
schedule_params = {
|
||||
"planCode": term_code,
|
||||
"sf_request_type": "ajax",
|
||||
}
|
||||
student_schedule_response_coro = conn.client.get(
|
||||
student_schedule_url,
|
||||
params=schedule_params,
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
section_and_time_response, student_schedule_response = await asyncio.gather(
|
||||
section_and_time_response_coro, student_schedule_response_coro
|
||||
)
|
||||
if section_and_time_response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取节次时间信息失败,状态码: {section_and_time_response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode.remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, message="无法获取节次时间信息,请稍后再试"
|
||||
)
|
||||
if student_schedule_response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取课表信息失败,状态码: {student_schedule_response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode.remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, message="无法获取课表信息,请稍后再试"
|
||||
)
|
||||
time_data = section_and_time_response.json()
|
||||
schedule_data = student_schedule_response.json()
|
||||
|
||||
# 处理时间段信息
|
||||
time_slots = []
|
||||
section_time = time_data.get("sectionTime", [])
|
||||
for time_slot in section_time:
|
||||
time_slots.append(
|
||||
{
|
||||
"session": time_slot.get("id", {}).get("session", 0),
|
||||
"session_name": time_slot.get("sessionName", ""),
|
||||
"start_time": time_slot.get("startTime", ""),
|
||||
"end_time": time_slot.get("endTime", ""),
|
||||
"time_length": time_slot.get("timeLength", ""),
|
||||
"djjc": time_slot.get("djjc", 0),
|
||||
}
|
||||
)
|
||||
|
||||
# 处理课程信息
|
||||
courses = []
|
||||
xkxx_list = schedule_data.get("xkxx", [])
|
||||
|
||||
for xkxx_item in xkxx_list:
|
||||
if isinstance(xkxx_item, dict):
|
||||
for course_key, course_data in xkxx_item.items():
|
||||
if isinstance(course_data, dict):
|
||||
# 提取基本课程信息
|
||||
course_name = course_data.get("courseName", "")
|
||||
course_code = course_data.get("id", {}).get("coureNumber", "")
|
||||
course_sequence = course_data.get("id", {}).get(
|
||||
"coureSequenceNumber", ""
|
||||
)
|
||||
teacher_name = (
|
||||
course_data.get("attendClassTeacher", "")
|
||||
.replace("* ", "")
|
||||
.strip()
|
||||
)
|
||||
course_properties = course_data.get("coursePropertiesName", "")
|
||||
exam_type = course_data.get("examTypeName", "")
|
||||
unit = float(course_data.get("unit", 0))
|
||||
|
||||
# 处理时间地点列表
|
||||
time_locations = []
|
||||
time_place_list = course_data.get("timeAndPlaceList", [])
|
||||
|
||||
# 检查是否有具体时间安排
|
||||
is_no_schedule = len(time_place_list) == 0
|
||||
|
||||
for time_place in time_place_list:
|
||||
# 过滤掉无用的字段,只保留关键信息
|
||||
time_location = {
|
||||
"class_day": time_place.get("classDay", 0),
|
||||
"class_sessions": time_place.get("classSessions", 0),
|
||||
"continuing_session": time_place.get(
|
||||
"continuingSession", 0
|
||||
),
|
||||
"class_week": time_place.get("classWeek", ""),
|
||||
"week_description": time_place.get(
|
||||
"weekDescription", ""
|
||||
),
|
||||
"campus_name": time_place.get("campusName", ""),
|
||||
"teaching_building_name": time_place.get(
|
||||
"teachingBuildingName", ""
|
||||
),
|
||||
"classroom_name": time_place.get("classroomName", ""),
|
||||
}
|
||||
time_locations.append(time_location)
|
||||
|
||||
# 只保留有效的课程(有课程名称的)
|
||||
if course_name:
|
||||
course = {
|
||||
"course_name": course_name,
|
||||
"course_code": course_code,
|
||||
"course_sequence": course_sequence,
|
||||
"teacher_name": teacher_name,
|
||||
"course_properties": course_properties,
|
||||
"exam_type": exam_type,
|
||||
"unit": unit,
|
||||
"time_locations": time_locations,
|
||||
"is_no_schedule": is_no_schedule,
|
||||
}
|
||||
courses.append(course)
|
||||
# 构建最终数据
|
||||
processed_data = {
|
||||
"total_units": float(schedule_data.get("allUnits", 0)),
|
||||
"time_slots": time_slots,
|
||||
"courses": courses,
|
||||
}
|
||||
|
||||
conn.logger.info(
|
||||
f"成功处理课表数据:共{len(courses)}门课程,{len(time_slots)}个时间段"
|
||||
)
|
||||
result = ScheduleData.model_validate(processed_data)
|
||||
return UniResponseModel[ScheduleData](
|
||||
success=True,
|
||||
data=result,
|
||||
message="获取课表信息成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"数据验证错误: {ve}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id, "数据验证错误"
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id,
|
||||
)
|
||||
176
loveace/router/endpoint/jwc/score.py
Normal file
176
loveace/router/endpoint/jwc/score.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import re
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from httpx import HTTPError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from loveace.router.endpoint.jwc.model.base import JWCConfig
|
||||
from loveace.router.endpoint.jwc.model.score import ScoreRecord, TermScoreResponse
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
jwc_score_router = APIRouter(
|
||||
prefix="/score",
|
||||
responses=ProtectRouterErrorToCode().gen_code_table(),
|
||||
)
|
||||
|
||||
ENDPOINT = {
|
||||
"term_score_pre": "/student/integratedQuery/scoreQuery/allTermScores/index",
|
||||
"term_score": "/student/integratedQuery/scoreQuery/{dynamic_path}/allTermScores/data",
|
||||
}
|
||||
|
||||
|
||||
@jwc_score_router.get(
|
||||
"/{term_code}/list",
|
||||
summary="获取给定学期成绩列表",
|
||||
response_model=UniResponseModel[TermScoreResponse],
|
||||
)
|
||||
async def get_term_score(
|
||||
term_code: str,
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[TermScoreResponse] | JSONResponse:
|
||||
"""
|
||||
获取指定学期的详细成绩单
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取指定学期所有课程成绩
|
||||
- 包含补考和重修成绩
|
||||
- 显示学分、绩点等详细信息
|
||||
|
||||
💡 使用场景:
|
||||
- 查看历史学期的成绩
|
||||
- 导出成绩单
|
||||
- 分析学业成绩趋势
|
||||
|
||||
Args:
|
||||
term_code: 学期代码(如:2023-2024-1)
|
||||
|
||||
Returns:
|
||||
TermScoreResponse: 包含该学期所有成绩记录和总数
|
||||
"""
|
||||
try:
|
||||
response = await conn.client.get(
|
||||
JWCConfig().to_full_url(ENDPOINT["term_score_pre"]),
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(f"访问成绩查询页面失败,状态码: {response.status_code}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
# 从页面中提取动态路径参数
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
# 查找表单或Ajax请求的URL
|
||||
# 通常在JavaScript代码中或表单action中
|
||||
dynamic_path = "M1uwxk14o6" # 默认值,如果无法提取则使用
|
||||
|
||||
# 尝试从页面中提取动态路径
|
||||
scripts = soup.find_all("script")
|
||||
for script in scripts:
|
||||
try:
|
||||
script_text = script.string # type: ignore
|
||||
if script_text and "allTermScores/data" in script_text:
|
||||
# 使用正则表达式提取路径
|
||||
match = re.search(
|
||||
r"/([A-Za-z0-9]+)/allTermScores/data", script_text
|
||||
)
|
||||
if match:
|
||||
dynamic_path = match.group(1)
|
||||
break
|
||||
except AttributeError:
|
||||
continue
|
||||
|
||||
data_url = JWCConfig().to_full_url(
|
||||
ENDPOINT["term_score"].format(dynamic_path=dynamic_path)
|
||||
)
|
||||
data_params = {
|
||||
"zxjxjhh": term_code,
|
||||
"kch": "",
|
||||
"kcm": "",
|
||||
"pageNum": "1",
|
||||
"pageSize": "50",
|
||||
"sf_request_type": "ajax",
|
||||
}
|
||||
data_response = await conn.client.post(
|
||||
data_url,
|
||||
data=data_params,
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
if data_response.status_code != 200:
|
||||
conn.logger.error(f"获取成绩数据失败,状态码: {data_response.status_code}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
data_json = data_response.json()
|
||||
data_list = data_json.get("list", {})
|
||||
if not data_list:
|
||||
result = TermScoreResponse(records=[], total_count=0)
|
||||
return UniResponseModel[TermScoreResponse](
|
||||
success=True,
|
||||
data=result,
|
||||
message="获取成绩单成功",
|
||||
error=None,
|
||||
)
|
||||
records = data_list.get("records", [])
|
||||
r_total_count = data_list.get("pageContext", {}).get("totalCount", 0)
|
||||
term_scores = []
|
||||
for record in records:
|
||||
term_scores.append(
|
||||
ScoreRecord(
|
||||
sequence=record[0],
|
||||
term_id=record[1],
|
||||
course_code=record[2],
|
||||
course_class=record[3],
|
||||
course_name_cn=record[4],
|
||||
course_name_en=record[5],
|
||||
credits=record[6],
|
||||
hours=record[7],
|
||||
course_type=record[8],
|
||||
exam_type=record[9],
|
||||
score=record[10],
|
||||
retake_score=record[11] if record[11] else None,
|
||||
makeup_score=record[12] if record[12] else None,
|
||||
)
|
||||
)
|
||||
l_total_count = len(term_scores)
|
||||
assert r_total_count == l_total_count
|
||||
result = TermScoreResponse(records=term_scores, total_count=r_total_count)
|
||||
return UniResponseModel[TermScoreResponse](
|
||||
success=True,
|
||||
data=result,
|
||||
message="获取成绩单成功",
|
||||
error=None,
|
||||
)
|
||||
except AssertionError as ae:
|
||||
conn.logger.error(f"数据属性错误: {ae}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except IndexError as ie:
|
||||
conn.logger.error(f"数据解析错误: {ie}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"数据验证错误: {ve}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"HTTP请求错误: {he}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
306
loveace/router/endpoint/jwc/term.py
Normal file
306
loveace/router/endpoint/jwc/term.py
Normal file
@@ -0,0 +1,306 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from httpx import HTTPError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from loveace.router.endpoint.jwc.model.base import JWCConfig
|
||||
from loveace.router.endpoint.jwc.model.term import CurrentTermInfo, TermItem
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
jwc_term_router = APIRouter(
|
||||
prefix="/term",
|
||||
responses=ProtectRouterErrorToCode().gen_code_table(),
|
||||
)
|
||||
|
||||
ENDPOINT = {
|
||||
"all_terms": "/student/courseSelect/calendarSemesterCurriculum/index",
|
||||
"calendar": "/indexCalendar",
|
||||
}
|
||||
|
||||
|
||||
@jwc_term_router.get(
|
||||
"/all",
|
||||
summary="获取所有学期信息",
|
||||
response_model=UniResponseModel[list[TermItem]],
|
||||
)
|
||||
async def get_all_terms(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[list[TermItem]] | JSONResponse:
|
||||
"""
|
||||
获取用户可选的所有学期列表
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取从入学至今的所有学期
|
||||
- 标记当前学期
|
||||
- 学期名称格式统一处理
|
||||
|
||||
💡 使用场景:
|
||||
- 选课系统的学期选择菜单
|
||||
- 成绩查询的学期选择
|
||||
- 课程表查询的学期选择
|
||||
|
||||
Returns:
|
||||
list[TermItem]: 学期列表,包含学期代码、名称、是否为当前学期
|
||||
"""
|
||||
try:
|
||||
all_terms = []
|
||||
response = await conn.client.get(
|
||||
JWCConfig().to_full_url(ENDPOINT["all_terms"]),
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(f"获取学期信息失败,状态码: {response.status_code}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
# 解析HTML获取学期选项
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
|
||||
# 查找学期选择下拉框
|
||||
select_element = soup.find("select", {"id": "planCode"})
|
||||
if not select_element:
|
||||
conn.logger.error("未找到学期选择框")
|
||||
return UniResponseModel[list[TermItem]](
|
||||
success=False,
|
||||
data=[],
|
||||
message="未找到学期选择框",
|
||||
error=None,
|
||||
)
|
||||
|
||||
terms = {}
|
||||
# 使用更安全的方式处理选项
|
||||
try:
|
||||
options = select_element.find_all("option") # type: ignore
|
||||
for option in options:
|
||||
value = option.get("value") # type: ignore
|
||||
text = option.get_text(strip=True) # type: ignore
|
||||
|
||||
# 跳过空值选项(如"全部")
|
||||
if value and str(value).strip() and text != "全部":
|
||||
terms[str(value)] = text
|
||||
except AttributeError:
|
||||
conn.logger.error("解析学期选项失败")
|
||||
return UniResponseModel[list[TermItem]](
|
||||
success=False,
|
||||
data=[],
|
||||
message="解析学期选项失败",
|
||||
error=None,
|
||||
)
|
||||
|
||||
conn.logger.info(f"成功获取{len(terms)}个学期信息")
|
||||
counter = 0
|
||||
# 遍历学期选项,提取学期代码和名称
|
||||
# 将学期中的 "春" 替换为 "下" , "秋" 替换为 "上"
|
||||
for key, value in terms.items():
|
||||
counter += 1
|
||||
value = value.replace("春", "下").replace("秋", "上")
|
||||
all_terms.append(
|
||||
TermItem(term_code=key, term_name=value, is_current=counter == 1)
|
||||
)
|
||||
|
||||
return UniResponseModel[list[TermItem]](
|
||||
success=True,
|
||||
data=all_terms,
|
||||
message="获取学期信息成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"数据验证错误: {ve}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"HTTP请求错误: {he}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
|
||||
@jwc_term_router.get(
|
||||
"/current",
|
||||
summary="获取当前学期信息",
|
||||
response_model=UniResponseModel[CurrentTermInfo],
|
||||
)
|
||||
async def get_current_term(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[CurrentTermInfo] | JSONResponse:
|
||||
"""
|
||||
获取当前学期的详细信息
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取当前学期的开始和结束日期
|
||||
- 获取学期周数信息
|
||||
- 实时从教务系统获取
|
||||
|
||||
💡 使用场景:
|
||||
- 显示当前学期进度
|
||||
- 课程表的周次显示参考
|
||||
- 学期时间提醒
|
||||
|
||||
Returns:
|
||||
CurrentTermInfo: 包含学期代码、名称、开始日期、结束日期等
|
||||
"""
|
||||
try:
|
||||
info_response = await conn.client.get(
|
||||
JWCConfig().DEFAULT_BASE_URL, follow_redirects=True, timeout=conn.timeout
|
||||
)
|
||||
if info_response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取学期信息页面失败,状态码: {info_response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
start_response = await conn.client.get(
|
||||
JWCConfig().to_full_url(ENDPOINT["calendar"]),
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
if start_response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取学期开始时间失败,状态码: {start_response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
# 提取学期开始时间
|
||||
flexible_pattern = r'var\s+rq\s*=\s*"(\d{8})";\s*//.*'
|
||||
match = re.findall(flexible_pattern, start_response.text)
|
||||
if not match:
|
||||
conn.logger.error("未找到学期开始时间")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
start_date_str = match[0]
|
||||
try:
|
||||
start_date = datetime.strptime(start_date_str, "%Y%m%d").date()
|
||||
except ValueError:
|
||||
conn.logger.error(f"学期开始时间格式错误: {start_date_str}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
start_date = datetime.strptime(start_date_str, "%Y%m%d").date()
|
||||
|
||||
html_content = info_response.text
|
||||
|
||||
# 使用BeautifulSoup解析HTML
|
||||
soup = BeautifulSoup(html_content, "html.parser")
|
||||
|
||||
# 查找包含学期周数信息的元素
|
||||
# 使用CSS选择器查找
|
||||
calendar_element = soup.select_one(
|
||||
"#navbar-container > div.navbar-buttons.navbar-header.pull-right > ul > li.light-red > a"
|
||||
)
|
||||
|
||||
if not calendar_element:
|
||||
# 如果CSS选择器失败,尝试其他方法
|
||||
# 查找包含"第X周"的元素
|
||||
potential_elements = soup.find_all("a", class_="dropdown-toggle")
|
||||
calendar_element = None
|
||||
|
||||
for element in potential_elements:
|
||||
text = element.get_text(strip=True) if element else ""
|
||||
if "第" in text and "周" in text:
|
||||
calendar_element = element
|
||||
break
|
||||
|
||||
# 如果还是找不到,尝试查找任何包含学期信息的元素
|
||||
if not calendar_element:
|
||||
all_elements = soup.find_all(text=re.compile(r"\d{4}-\d{4}.*第\d+周"))
|
||||
if all_elements:
|
||||
# 找到包含学期信息的文本,查找其父元素
|
||||
for text_node in all_elements:
|
||||
parent = text_node.parent
|
||||
if parent:
|
||||
calendar_element = parent
|
||||
break
|
||||
|
||||
if not calendar_element:
|
||||
conn.logger.warning("未找到学期周数信息元素")
|
||||
|
||||
# 尝试在整个页面中搜索学期信息模式
|
||||
semester_pattern = re.search(
|
||||
r"(\d{4}-\d{4})\s*(春|秋|夏)?\s*第(\d+)周\s*(星期[一二三四五六日天])?",
|
||||
html_content,
|
||||
)
|
||||
if semester_pattern:
|
||||
calendar_text = semester_pattern.group(0)
|
||||
conn.logger.info(f"通过正则表达式找到学期信息: {calendar_text}")
|
||||
else:
|
||||
conn.logger.debug(f"HTML内容长度: {len(html_content)}")
|
||||
conn.logger.debug(
|
||||
"未检测到学期周数相关内容,可能需要重新登录或检查访问权限"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
else:
|
||||
# 提取文本内容
|
||||
calendar_text = calendar_element.get_text(strip=True)
|
||||
conn.logger.info(f"找到学期周数信息: {calendar_text}")
|
||||
clean_text = re.sub(r"\s+", " ", calendar_text.strip())
|
||||
|
||||
# 初始化默认值
|
||||
academic_year = ""
|
||||
term = ""
|
||||
week_number = 0
|
||||
is_end = False
|
||||
|
||||
try:
|
||||
# 解析学年:2025-2026
|
||||
year_match = re.search(r"(\d{4}-\d{4})", clean_text)
|
||||
if year_match:
|
||||
academic_year = year_match.group(1)
|
||||
|
||||
# 解析学期:秋、春
|
||||
semester_match = re.search(r"(春|秋)", clean_text)
|
||||
if semester_match:
|
||||
term = semester_match.group(1)
|
||||
|
||||
# 解析周数:第1周、第15周等
|
||||
week_match = re.search(r"第(\d+)周", clean_text)
|
||||
if week_match:
|
||||
week_number = int(week_match.group(1))
|
||||
|
||||
# 判断是否为学期结束(通常第16周以后或包含"结束"等关键词)
|
||||
if week_number >= 16 or "结束" in clean_text or "考试" in clean_text:
|
||||
is_end = True
|
||||
|
||||
except Exception as e:
|
||||
conn.logger.warning(f"解析学期周数信息时出错: {str(e)}")
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
result = CurrentTermInfo(
|
||||
academic_year=academic_year,
|
||||
current_term_name=term,
|
||||
week_number=week_number,
|
||||
start_at=start_date.strftime("%Y-%m-%d"),
|
||||
is_end=is_end,
|
||||
weekday=datetime.now().weekday(),
|
||||
)
|
||||
return UniResponseModel[CurrentTermInfo](
|
||||
success=True,
|
||||
data=result,
|
||||
message="获取当前学期信息成功",
|
||||
error=None,
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
96
loveace/router/endpoint/jwc/utils/aspnet_form_parser.py
Normal file
96
loveace/router/endpoint/jwc/utils/aspnet_form_parser.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
ASP.NET 表单解析器
|
||||
用于从 ASP.NET 页面中提取动态表单数据
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
class ASPNETFormParser:
|
||||
"""ASP.NET 表单解析器"""
|
||||
|
||||
@staticmethod
|
||||
def extract_form_data(html_content: str) -> Dict[str, str]:
|
||||
"""
|
||||
从 ASP.NET 页面 HTML 中提取表单数据
|
||||
|
||||
Args:
|
||||
html_content: HTML 页面内容
|
||||
|
||||
Returns:
|
||||
包含表单字段的字典
|
||||
"""
|
||||
|
||||
return ASPNETFormParser._extract_with_beautifulsoup(html_content)
|
||||
|
||||
@staticmethod
|
||||
def _extract_with_beautifulsoup(html_content: str) -> Dict[str, str]:
|
||||
"""
|
||||
使用 BeautifulSoup 提取表单数据
|
||||
|
||||
Args:
|
||||
html_content: HTML 页面内容
|
||||
|
||||
Returns:
|
||||
包含表单字段的字典
|
||||
"""
|
||||
form_data = {}
|
||||
|
||||
# 使用 BeautifulSoup 解析 HTML
|
||||
soup = BeautifulSoup(html_content, "lxml")
|
||||
|
||||
# 查找表单
|
||||
form = soup.find("form", {"method": "post"})
|
||||
if not form:
|
||||
raise ValueError("未找到 POST 表单")
|
||||
|
||||
# 提取隐藏字段
|
||||
hidden_fields = [
|
||||
"__EVENTTARGET",
|
||||
"__EVENTARGUMENT",
|
||||
"__LASTFOCUS",
|
||||
"__VIEWSTATE",
|
||||
"__VIEWSTATEGENERATOR",
|
||||
"__EVENTVALIDATION",
|
||||
]
|
||||
|
||||
for field_name in hidden_fields:
|
||||
input_element = form.find("input", {"name": field_name})
|
||||
if input_element and input_element.get("value"):
|
||||
form_data[field_name] = input_element.get("value")
|
||||
else:
|
||||
form_data[field_name] = ""
|
||||
|
||||
# 添加其他表单字段的默认值
|
||||
form_data.update(
|
||||
{
|
||||
"ctl00$ContentPlaceHolder1$ContentPlaceHolder2$ddlSslb": "%",
|
||||
"ctl00$ContentPlaceHolder1$ContentPlaceHolder2$txtSsmc": "",
|
||||
"ctl00$ContentPlaceHolder1$ContentPlaceHolder2$gvSb$ctl28$txtNewPageIndex": "1",
|
||||
}
|
||||
)
|
||||
|
||||
return form_data
|
||||
|
||||
@staticmethod
|
||||
def get_awards_list_form_data(html_content: str) -> Dict[str, str]:
|
||||
"""
|
||||
获取已申报奖项列表页面的表单数据
|
||||
|
||||
Args:
|
||||
html_content: HTML 页面内容
|
||||
|
||||
Returns:
|
||||
用于请求已申报奖项的表单数据
|
||||
"""
|
||||
base_form_data = ASPNETFormParser.extract_form_data(html_content)
|
||||
|
||||
# 设置 EVENTTARGET 为"已申报奖项"选项卡
|
||||
base_form_data["__EVENTTARGET"] = (
|
||||
"ctl00$ContentPlaceHolder1$ContentPlaceHolder2$DataList1$ctl01$LinkButton1"
|
||||
)
|
||||
|
||||
return base_form_data
|
||||
267
loveace/router/endpoint/jwc/utils/competition.py
Normal file
267
loveace/router/endpoint/jwc/utils/competition.py
Normal file
@@ -0,0 +1,267 @@
|
||||
from typing import Optional
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from loveace.router.endpoint.jwc.model.competition import (
|
||||
AwardProject,
|
||||
CompetitionAwardsResponse,
|
||||
CompetitionCreditsSummaryResponse,
|
||||
CompetitionFullResponse,
|
||||
CreditsSummary,
|
||||
)
|
||||
|
||||
|
||||
class CompetitionInfoParser:
|
||||
"""
|
||||
创新创业管理平台信息解析器
|
||||
|
||||
功能:
|
||||
- 解析获奖项目列表(表格数据)
|
||||
- 解析学分汇总信息
|
||||
- 提取学生基本信息
|
||||
"""
|
||||
|
||||
def __init__(self, html_content: str):
|
||||
"""
|
||||
初始化解析器
|
||||
|
||||
参数:
|
||||
html_content: HTML页面内容字符串
|
||||
"""
|
||||
self.soup = BeautifulSoup(html_content, "html.parser")
|
||||
|
||||
def parse_awards(self) -> CompetitionAwardsResponse:
|
||||
"""
|
||||
解析获奖项目列表
|
||||
|
||||
返回:
|
||||
CompetitionAwardsResponse: 包含获奖项目列表的响应对象
|
||||
"""
|
||||
# 解析学生ID
|
||||
student_id = self._parse_student_id()
|
||||
|
||||
# 解析项目列表
|
||||
projects = self._parse_projects()
|
||||
|
||||
response = CompetitionAwardsResponse(
|
||||
student_id=student_id,
|
||||
total_count=len(projects),
|
||||
awards=projects,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def parse_credits_summary(self) -> CompetitionCreditsSummaryResponse:
|
||||
"""
|
||||
解析学分汇总信息
|
||||
|
||||
返回:
|
||||
CompetitionCreditsSummaryResponse: 包含学分汇总信息的响应对象
|
||||
"""
|
||||
# 解析学生ID
|
||||
student_id = self._parse_student_id()
|
||||
|
||||
# 解析学分汇总
|
||||
credits_summary = self._parse_credits_summary()
|
||||
|
||||
response = CompetitionCreditsSummaryResponse(
|
||||
student_id=student_id,
|
||||
credits_summary=credits_summary,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def parse_full_competition_info(self) -> CompetitionFullResponse:
|
||||
"""
|
||||
解析完整的学科竞赛信息(获奖项目 + 学分汇总)
|
||||
|
||||
一次性解析HTML,同时提取获奖项目列表和学分汇总信息,
|
||||
减少网络IO和数据库查询次数
|
||||
|
||||
返回:
|
||||
CompetitionFullResponse: 包含完整竞赛信息的响应对象
|
||||
"""
|
||||
# 解析学生ID
|
||||
student_id = self._parse_student_id()
|
||||
|
||||
# 解析项目列表
|
||||
projects = self._parse_projects()
|
||||
|
||||
# 解析学分汇总
|
||||
credits_summary = self._parse_credits_summary()
|
||||
|
||||
response = CompetitionFullResponse(
|
||||
student_id=student_id,
|
||||
total_awards_count=len(projects),
|
||||
awards=projects,
|
||||
credits_summary=credits_summary,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def _parse_student_id(self) -> str:
|
||||
"""
|
||||
解析学生基本信息 - 学生ID/工号
|
||||
|
||||
返回:
|
||||
str: 学生ID,如果未找到返回空字符串
|
||||
"""
|
||||
student_span = self.soup.find("span", id="ContentPlaceHolder1_lblXM")
|
||||
if student_span:
|
||||
text = student_span.get_text(strip=True)
|
||||
# 格式: "欢迎您:20244787"
|
||||
if ":" in text:
|
||||
return text.split(":")[1].strip()
|
||||
return ""
|
||||
|
||||
def _parse_projects(self) -> list:
|
||||
"""
|
||||
解析获奖项目列表
|
||||
|
||||
数据来源: 页面中ID为 ContentPlaceHolder1_ContentPlaceHolder2_gvHj 的表格
|
||||
|
||||
表格结构:
|
||||
- 第一行为表头
|
||||
- 后续行为项目数据
|
||||
- 包含15列数据
|
||||
|
||||
返回:
|
||||
list[AwardProject]: 获奖项目列表
|
||||
"""
|
||||
projects = []
|
||||
|
||||
# 查找项目列表表格
|
||||
table = self.soup.find(
|
||||
"table", id="ContentPlaceHolder1_ContentPlaceHolder2_gvHj"
|
||||
)
|
||||
if not table:
|
||||
return projects
|
||||
|
||||
rows = table.find_all("tr")
|
||||
# 跳过表头行(第一行)
|
||||
for row in rows[1:]:
|
||||
cells = row.find_all("td")
|
||||
if len(cells) < 9: # 至少需要9列数据
|
||||
continue
|
||||
|
||||
try:
|
||||
project = AwardProject(
|
||||
project_id=cells[0].get_text(strip=True),
|
||||
project_name=cells[1].get_text(strip=True),
|
||||
level=cells[2].get_text(strip=True),
|
||||
grade=cells[3].get_text(strip=True),
|
||||
award_date=cells[4].get_text(strip=True),
|
||||
applicant_id=cells[5].get_text(strip=True),
|
||||
applicant_name=cells[6].get_text(strip=True),
|
||||
order=int(cells[7].get_text(strip=True)),
|
||||
credits=float(cells[8].get_text(strip=True)),
|
||||
bonus=float(cells[9].get_text(strip=True)),
|
||||
status=cells[10].get_text(strip=True),
|
||||
verification_status=cells[11].get_text(strip=True),
|
||||
)
|
||||
projects.append(project)
|
||||
except (ValueError, IndexError):
|
||||
# 数据格式异常,记录但继续处理
|
||||
continue
|
||||
|
||||
return projects
|
||||
|
||||
def _parse_credits_summary(self) -> Optional[CreditsSummary]:
|
||||
"""
|
||||
解析学分汇总信息
|
||||
|
||||
数据来源: 页面中的学分汇总表中的各类学分 span 元素
|
||||
|
||||
提取内容:
|
||||
- 学科竞赛学分
|
||||
- 科研项目学分
|
||||
- 可转竞赛类学分
|
||||
- 创新创业实践学分
|
||||
- 能力资格认证学分
|
||||
- 其他项目学分
|
||||
|
||||
返回:
|
||||
CreditsSummary: 学分汇总对象,如果无法解析则返回 None
|
||||
"""
|
||||
discipline_competition_credits = None
|
||||
scientific_research_credits = None
|
||||
transferable_competition_credits = None
|
||||
innovation_practice_credits = None
|
||||
ability_certification_credits = None
|
||||
other_project_credits = None
|
||||
|
||||
# 查找学科竞赛学分
|
||||
xkjs_span = self.soup.find(
|
||||
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblXkjsxf"
|
||||
)
|
||||
if xkjs_span:
|
||||
text = xkjs_span.get_text(strip=True)
|
||||
discipline_competition_credits = self._parse_credit_value(text)
|
||||
|
||||
# 查找科研项目学分
|
||||
ky_span = self.soup.find(
|
||||
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblKyxf"
|
||||
)
|
||||
if ky_span:
|
||||
text = ky_span.get_text(strip=True)
|
||||
scientific_research_credits = self._parse_credit_value(text)
|
||||
|
||||
# 查找可转竞赛类学分
|
||||
kzjsl_span = self.soup.find(
|
||||
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblKzjslxf"
|
||||
)
|
||||
if kzjsl_span:
|
||||
text = kzjsl_span.get_text(strip=True)
|
||||
transferable_competition_credits = self._parse_credit_value(text)
|
||||
|
||||
# 查找创新创业实践学分
|
||||
cxcy_span = self.soup.find(
|
||||
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblCxcyxf"
|
||||
)
|
||||
if cxcy_span:
|
||||
text = cxcy_span.get_text(strip=True)
|
||||
innovation_practice_credits = self._parse_credit_value(text)
|
||||
|
||||
# 查找能力资格认证学分
|
||||
nlzg_span = self.soup.find(
|
||||
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblNlzgxf"
|
||||
)
|
||||
if nlzg_span:
|
||||
text = nlzg_span.get_text(strip=True)
|
||||
ability_certification_credits = self._parse_credit_value(text)
|
||||
|
||||
# 查找其他项目学分
|
||||
qt_span = self.soup.find(
|
||||
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblQtxf"
|
||||
)
|
||||
if qt_span:
|
||||
text = qt_span.get_text(strip=True)
|
||||
other_project_credits = self._parse_credit_value(text)
|
||||
|
||||
return CreditsSummary(
|
||||
discipline_competition_credits=discipline_competition_credits,
|
||||
scientific_research_credits=scientific_research_credits,
|
||||
transferable_competition_credits=transferable_competition_credits,
|
||||
innovation_practice_credits=innovation_practice_credits,
|
||||
ability_certification_credits=ability_certification_credits,
|
||||
other_project_credits=other_project_credits,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_credit_value(text: str) -> Optional[float]:
|
||||
"""
|
||||
解析学分值
|
||||
|
||||
参数:
|
||||
text: 文本值,可能为"0", "16.60", "无"等
|
||||
|
||||
返回:
|
||||
float: 学分值,如果为"无"或无法解析则返回 None
|
||||
"""
|
||||
text = text.strip()
|
||||
if text == "无" or text == "":
|
||||
return None
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
337
loveace/router/endpoint/jwc/utils/exam.py
Normal file
337
loveace/router/endpoint/jwc/utils/exam.py
Normal file
@@ -0,0 +1,337 @@
|
||||
import time
|
||||
from json import JSONDecodeError
|
||||
from typing import List, Optional
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from loveace.router.endpoint.jwc.model.base import JWCConfig
|
||||
from loveace.router.endpoint.jwc.model.exam import (
|
||||
ExamInfoResponse,
|
||||
ExamScheduleItem,
|
||||
OtherExamRecord,
|
||||
OtherExamResponse,
|
||||
SeatInfo,
|
||||
UnifiedExamInfo,
|
||||
)
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
|
||||
ENDPOINTS = {
|
||||
"school_exam_pre_request": "/student/examinationManagement/examPlan/index",
|
||||
"school_exam_request": "/student/examinationManagement/examPlan/detail",
|
||||
"seat_info": "/student/examinationManagement/examPlan/index",
|
||||
"other_exam_record": "/student/examinationManagement/othersExamPlan/queryScores?sf_request_type=ajax",
|
||||
}
|
||||
|
||||
|
||||
# +++++===== 考试信息前置方法 =====+++++ #
|
||||
async def fetch_school_exam_schedule(
|
||||
start_date: str, end_date: str, conn: AUFEConnection
|
||||
) -> List[ExamScheduleItem]:
|
||||
"""
|
||||
获取校统考考试安排
|
||||
|
||||
Args:
|
||||
start_date: 开始日期 (YYYY-MM-DD)
|
||||
end_date: 结束日期 (YYYY-MM-DD)
|
||||
|
||||
Returns:
|
||||
List[ExamScheduleItem]: 校统考列表
|
||||
"""
|
||||
try:
|
||||
timestamp = int(time.time() * 1000)
|
||||
|
||||
headers = {
|
||||
**conn.client.headers,
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
}
|
||||
|
||||
params = {
|
||||
"start": start_date,
|
||||
"end": end_date,
|
||||
"_": str(timestamp),
|
||||
"sf_request_type": "ajax",
|
||||
}
|
||||
await conn.client.get(
|
||||
url=JWCConfig().to_full_url(ENDPOINTS["school_exam_pre_request"]),
|
||||
follow_redirects=True,
|
||||
headers=headers,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
response = await conn.client.get(
|
||||
url=JWCConfig().to_full_url(ENDPOINTS["school_exam_request"]),
|
||||
headers=headers,
|
||||
params=params,
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(f"获取校统考信息失败: HTTP状态码 {response.status_code}")
|
||||
return []
|
||||
if "]" == response.text:
|
||||
conn.logger.warning("获取校统考信息成功,但无数据")
|
||||
return []
|
||||
try:
|
||||
json_data = response.json()
|
||||
except JSONDecodeError as e:
|
||||
conn.logger.error(f"解析校统考信息JSON失败: {str(e)}")
|
||||
return []
|
||||
|
||||
# 解析为ExamScheduleItem列表
|
||||
school_exams = []
|
||||
if isinstance(json_data, list):
|
||||
for item in json_data:
|
||||
exam_item = ExamScheduleItem.model_validate(item)
|
||||
school_exams.append(exam_item)
|
||||
|
||||
conn.logger.info(f"获取校统考信息成功,共 {len(school_exams)} 场考试")
|
||||
return school_exams
|
||||
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取校统考信息出现如下异常: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_exam_seat_info(conn: AUFEConnection) -> List[SeatInfo]:
|
||||
"""
|
||||
获取考试座位号信息
|
||||
conn: AUFEConnection
|
||||
|
||||
Returns:
|
||||
List[SeatInfo]: 座位信息列表
|
||||
"""
|
||||
try:
|
||||
headers = {
|
||||
**conn.client.headers,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
}
|
||||
|
||||
response = await conn.client.get(
|
||||
url=JWCConfig().to_full_url(ENDPOINTS["seat_info"]),
|
||||
headers=headers,
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取考试座位号信息失败: HTTP状态码 {response.status_code}"
|
||||
)
|
||||
return []
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
seat_infos = []
|
||||
|
||||
# 查找所有考试信息区块
|
||||
exam_blocks = soup.find_all("div", {"class": "widget-box"})
|
||||
for block in exam_blocks:
|
||||
course_name = ""
|
||||
seat_number = ""
|
||||
|
||||
# 获取课程名
|
||||
title = block.find("h5", {"class": "widget-title"}) # type: ignore
|
||||
if title:
|
||||
course_text = title.get_text(strip=True) # type: ignore
|
||||
# 提取课程名,格式可能是: "(课程代码-班号)课程名"
|
||||
if ")" in course_text:
|
||||
course_name = course_text.split(")", 1)[1].strip()
|
||||
else:
|
||||
course_name = course_text.strip()
|
||||
|
||||
# 获取座位号
|
||||
widget_main = block.find("div", {"class": "widget-main"}) # type: ignore
|
||||
if widget_main:
|
||||
content = widget_main.get_text() # type: ignore
|
||||
for line in content.split("\n"):
|
||||
if "座位号" in line:
|
||||
try:
|
||||
seat_number = line.split("座位号:")[1].strip()
|
||||
except Exception:
|
||||
try:
|
||||
seat_number = line.split("座位号:")[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
if course_name and seat_number:
|
||||
seat_infos.append(
|
||||
SeatInfo(course_name=course_name, seat_number=seat_number)
|
||||
)
|
||||
|
||||
conn.logger.info(f"获取考试座位号信息成功,共 {len(seat_infos)} 条记录")
|
||||
return seat_infos
|
||||
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取考试座位号信息异常: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def convert_school_exam_to_unified(
|
||||
exam: ExamScheduleItem, seat_infos: List[SeatInfo], conn: AUFEConnection
|
||||
) -> Optional[UnifiedExamInfo]:
|
||||
"""
|
||||
将校统考数据转换为统一格式
|
||||
|
||||
Args:
|
||||
exam: 校统考项目
|
||||
seat_info: 座位号信息映射
|
||||
|
||||
Returns:
|
||||
Optional[UnifiedExamInfo]: 统一格式的考试信息
|
||||
"""
|
||||
try:
|
||||
# 解析title信息,格式如: "新媒体导论\n08:30-10:30\n西校\n西校通慧楼\n通慧楼-308\n"
|
||||
title_parts = exam.title.strip().split("\n")
|
||||
if len(title_parts) < 2:
|
||||
return None
|
||||
|
||||
course_name = title_parts[0]
|
||||
exam_time = title_parts[1] if len(title_parts) > 1 else ""
|
||||
|
||||
# 拼接地点信息
|
||||
location_parts = title_parts[2:] if len(title_parts) > 2 else []
|
||||
exam_location = " ".join([part for part in location_parts if part.strip()])
|
||||
|
||||
# 添加座位号到备注
|
||||
note = ""
|
||||
for seat in seat_infos:
|
||||
if seat.course_name == course_name:
|
||||
note = f"座位号: {seat.seat_number}"
|
||||
note = note.removesuffix("准考证号:")
|
||||
break
|
||||
|
||||
return UnifiedExamInfo(
|
||||
course_name=course_name,
|
||||
exam_date=exam.start,
|
||||
exam_time=exam_time,
|
||||
exam_location=exam_location,
|
||||
exam_type="校统考",
|
||||
note=note,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
conn.logger.error(f"转换校统考数据异常: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_other_exam_records(
|
||||
term_code: str, conn: AUFEConnection
|
||||
) -> List[OtherExamRecord]:
|
||||
"""
|
||||
获取其他考试记录
|
||||
|
||||
Args:
|
||||
term_code: 学期代码
|
||||
conn: AUFEConnection
|
||||
|
||||
Returns:
|
||||
List: 其他考试记录列表
|
||||
"""
|
||||
try:
|
||||
headers = {
|
||||
**conn.client.headers,
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
}
|
||||
|
||||
data = {"zxjxjhh": term_code, "tab": "0", "pageNum": "1", "pageSize": "30"}
|
||||
|
||||
response = await conn.client.post(
|
||||
url=JWCConfig().to_full_url(ENDPOINTS["other_exam_record"]),
|
||||
headers=headers,
|
||||
data=data,
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
valid = OtherExamResponse.model_validate_json(response.text)
|
||||
if valid.records:
|
||||
conn.logger.info(f"获取其他考试信息成功,共 {len(valid.records)} 条记录")
|
||||
return valid.records
|
||||
else:
|
||||
conn.logger.warning("获取其他考试信息成功,但无记录")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取其他考试信息出现如下异常: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def convert_other_exam_to_unified(
|
||||
record: OtherExamRecord, conn: AUFEConnection
|
||||
) -> Optional[UnifiedExamInfo]:
|
||||
"""
|
||||
将其他考试记录转换为统一格式
|
||||
|
||||
Args:
|
||||
record: 其他考试记录
|
||||
|
||||
Returns:
|
||||
Optional[UnifiedExamInfo]: 统一格式的考试信息
|
||||
"""
|
||||
try:
|
||||
return UnifiedExamInfo(
|
||||
course_name=record.course_name,
|
||||
exam_date=record.exam_date,
|
||||
exam_time=record.exam_time,
|
||||
exam_location=record.exam_location,
|
||||
exam_type="其他考试",
|
||||
note=record.note,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
conn.logger.error(f"转换其他考试数据异常: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_unified_exam_info(
|
||||
conn: AUFEConnection,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
term_code: str = "2024-2025-2-1",
|
||||
) -> ExamInfoResponse:
|
||||
"""
|
||||
获取统一的考试信息,包括校统考和其他考试
|
||||
|
||||
Args:
|
||||
start_date: 开始日期 (YYYY-MM-DD)
|
||||
end_date: 结束日期 (YYYY-MM-DD)
|
||||
term_code: 学期代码,默认为当前学期
|
||||
|
||||
Returns:
|
||||
ExamInfoResponse: 统一的考试信息响应
|
||||
"""
|
||||
try:
|
||||
# 合并并转换为统一格式
|
||||
unified_exams = []
|
||||
# 获取校统考信息
|
||||
if school_exams := await fetch_school_exam_schedule(start_date, end_date, conn):
|
||||
# 获取座位号信息
|
||||
seat_info = await fetch_exam_seat_info(conn)
|
||||
# 处理校统考数据
|
||||
for exam in school_exams:
|
||||
unified_exam = convert_school_exam_to_unified(exam, seat_info, conn)
|
||||
if unified_exam:
|
||||
unified_exams.append(unified_exam)
|
||||
|
||||
# 获取其他考试信息
|
||||
other_exams = await fetch_other_exam_records(term_code, conn)
|
||||
# 处理其他考试数据
|
||||
for record in other_exams:
|
||||
unified_exam = convert_other_exam_to_unified(record, conn)
|
||||
if unified_exam:
|
||||
unified_exams.append(unified_exam)
|
||||
|
||||
# 按考试日期排序
|
||||
def _sort_key(exam: UnifiedExamInfo) -> str:
|
||||
return exam.exam_date + " " + exam.exam_time
|
||||
|
||||
unified_exams.sort(key=_sort_key)
|
||||
|
||||
return ExamInfoResponse(
|
||||
exams=unified_exams,
|
||||
total_count=len(unified_exams),
|
||||
)
|
||||
|
||||
except Exception:
|
||||
raise
|
||||
67
loveace/router/endpoint/jwc/utils/plan.py
Normal file
67
loveace/router/endpoint/jwc/utils/plan.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from loveace.router.endpoint.jwc.model.plan import (
|
||||
PlanCompletionCategory,
|
||||
PlanCompletionCourse,
|
||||
)
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
|
||||
|
||||
def populate_category_children(
|
||||
category: PlanCompletionCategory,
|
||||
category_id: str,
|
||||
nodes_by_id: dict,
|
||||
conn: AUFEConnection,
|
||||
):
|
||||
"""填充分类的子分类和课程(支持多层嵌套)"""
|
||||
try:
|
||||
children_count = 0
|
||||
subcategory_count = 0
|
||||
course_count = 0
|
||||
|
||||
for node in nodes_by_id.values():
|
||||
if node.get("pId") == category_id:
|
||||
children_count += 1
|
||||
flag_type = node.get("flagType", "")
|
||||
|
||||
if flag_type in ["001", "002"]: # 分类或子分类
|
||||
subcategory = PlanCompletionCategory.from_ztree_node(node)
|
||||
# 递归处理子项,支持多层嵌套
|
||||
populate_category_children(
|
||||
subcategory, node["id"], nodes_by_id, conn
|
||||
)
|
||||
category.subcategories.append(subcategory)
|
||||
subcategory_count += 1
|
||||
elif flag_type == "kch": # 课程
|
||||
course = PlanCompletionCourse.from_ztree_node(node)
|
||||
category.courses.append(course)
|
||||
course_count += 1
|
||||
else:
|
||||
# 处理其他类型的节点,也可能是分类
|
||||
# 根据是否有子节点来判断是分类还是课程
|
||||
has_children = any(
|
||||
n.get("pId") == node["id"] for n in nodes_by_id.values()
|
||||
)
|
||||
if has_children:
|
||||
# 有子节点,当作分类处理
|
||||
subcategory = PlanCompletionCategory.from_ztree_node(node)
|
||||
populate_category_children(
|
||||
subcategory, node["id"], nodes_by_id, conn
|
||||
)
|
||||
category.subcategories.append(subcategory)
|
||||
subcategory_count += 1
|
||||
else:
|
||||
# 无子节点,当作课程处理
|
||||
course = PlanCompletionCourse.from_ztree_node(node)
|
||||
category.courses.append(course)
|
||||
course_count += 1
|
||||
|
||||
if children_count > 0:
|
||||
conn.logger.info(
|
||||
f"分类 '{category.category_name}' (ID: {category_id}) 的子项: 总数={children_count}, 子分类={subcategory_count}, 课程={course_count}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
conn.logger.error(f"填充分类子项异常: {str(e)}")
|
||||
conn.logger.error(
|
||||
f"异常节点信息: category_id={category_id}, 错误详情: {str(e)}"
|
||||
)
|
||||
raise
|
||||
27
loveace/router/endpoint/jwc/utils/zxjxjhh_to_term_format.py
Normal file
27
loveace/router/endpoint/jwc/utils/zxjxjhh_to_term_format.py
Normal file
@@ -0,0 +1,27 @@
|
||||
def convert_zxjxjhh_to_term_format(zxjxjhh: str) -> str:
|
||||
"""
|
||||
转换学期格式
|
||||
xxxx-yyyy-1-1 -> xxxx-yyyy秋季学期
|
||||
xxxx-yyyy-2-1 -> xxxx-yyyy春季学期
|
||||
|
||||
Args:
|
||||
zxjxjhh: 学期代码,如 "2025-2026-1-1"
|
||||
|
||||
Returns:
|
||||
str: 转换后的学期名称,如 "2025-2026秋季学期"
|
||||
"""
|
||||
try:
|
||||
parts = zxjxjhh.split("-")
|
||||
if len(parts) >= 3:
|
||||
year_start = parts[0]
|
||||
year_end = parts[1]
|
||||
semester_num = parts[2]
|
||||
|
||||
if semester_num == "1":
|
||||
return f"{year_start}-{year_end}秋季学期"
|
||||
elif semester_num == "2":
|
||||
return f"{year_start}-{year_end}春季学期"
|
||||
|
||||
return zxjxjhh # 如果格式不匹配,返回原值
|
||||
except Exception:
|
||||
return zxjxjhh
|
||||
10
loveace/router/endpoint/ldjlb/__init__.py
Normal file
10
loveace/router/endpoint/ldjlb/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from loveace.router.endpoint.ldjlb.labor import ldjlb_labor_router
|
||||
|
||||
ldjlb_base_router = APIRouter(
|
||||
prefix="/ldjlb",
|
||||
tags=["劳动俱乐部"],
|
||||
)
|
||||
|
||||
ldjlb_base_router.include_router(ldjlb_labor_router)
|
||||
703
loveace/router/endpoint/ldjlb/labor.py
Normal file
703
loveace/router/endpoint/ldjlb/labor.py
Normal file
@@ -0,0 +1,703 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from httpx import Headers, HTTPError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from loveace.router.endpoint.ldjlb.model.base import LDJLBConfig
|
||||
from loveace.router.endpoint.ldjlb.model.ldjlb import (
|
||||
ActivityDetailResponse,
|
||||
LDJLBActivityListResponse,
|
||||
LDJLBApplyResponse,
|
||||
LDJLBClubListResponse,
|
||||
LDJLBProgressInfo,
|
||||
ScanSignRequest,
|
||||
ScanSignResponse,
|
||||
SignListResponse,
|
||||
)
|
||||
from loveace.router.endpoint.ldjlb.utils.ldjlb_ticket import get_ldjlb_header
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
ldjlb_labor_router = APIRouter(
|
||||
prefix="/labor",
|
||||
responses=ProtectRouterErrorToCode().gen_code_table(),
|
||||
)
|
||||
|
||||
ENDPOINT = {
|
||||
"progress": "/User/Activity/GetMyFinishCount?sf_request_type=ajax",
|
||||
"joined_activities": "/User/Activity/DoGetJoinPageList?sf_request_type=ajax",
|
||||
"joined_clubs": "/User/Club/DoGetJoinList?sf_request_type=ajax",
|
||||
"club_activities": "/User/Activity/DoGetPageList?sf_request_type=ajax",
|
||||
"apply_join": "/User/Activity/DoApplyJoin?sf_request_type=ajax",
|
||||
"scan_sign": "/User/Center/DoScanSignQRImage",
|
||||
"sign_list": "/User/Activity/DoGetSignList",
|
||||
"activity_detail": "/User/Activity/DoGetDetail",
|
||||
}
|
||||
|
||||
|
||||
@ldjlb_labor_router.get(
|
||||
"/progress",
|
||||
response_model=UniResponseModel[LDJLBProgressInfo],
|
||||
summary="获取劳动俱乐部修课进度",
|
||||
)
|
||||
async def get_labor_progress(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
headers: Headers = Depends(get_ldjlb_header),
|
||||
) -> UniResponseModel[LDJLBProgressInfo] | JSONResponse:
|
||||
"""
|
||||
获取用户的劳动俱乐部修课进度
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取已完成的劳动活动数量
|
||||
- 计算修课进度百分比(满分10次)
|
||||
- 实时从劳动俱乐部服务获取最新数据
|
||||
|
||||
💡 使用场景:
|
||||
- 个人中心显示劳动修课进度
|
||||
- 检查是否满足劳动教育要求
|
||||
- 了解还需完成的活动次数
|
||||
|
||||
Returns:
|
||||
LDJLBProgressInfo: 包含已完成次数和进度百分比
|
||||
"""
|
||||
try:
|
||||
conn.logger.info("开始获取劳动俱乐部修课进度")
|
||||
response = await conn.client.post(
|
||||
url=LDJLBConfig().to_full_url(ENDPOINT["progress"]),
|
||||
data={},
|
||||
headers=headers,
|
||||
timeout=6000,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取劳动俱乐部修课进度失败,HTTP状态码: {response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取劳动俱乐部修课进度失败,请稍后重试"
|
||||
)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
conn.logger.error(
|
||||
f"获取劳动俱乐部修课进度失败,响应代码: {data.get('code')}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取劳动俱乐部修课进度失败,请稍后重试"
|
||||
)
|
||||
try:
|
||||
progress_info = LDJLBProgressInfo.model_validate(data)
|
||||
conn.logger.info(
|
||||
f"成功获取劳动俱乐部修课进度: 已完成 {progress_info.finish_count}/10 次"
|
||||
)
|
||||
return UniResponseModel[LDJLBProgressInfo](
|
||||
success=True,
|
||||
data=progress_info,
|
||||
message="获取劳动俱乐部修课进度成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"解析劳动俱乐部修课进度失败: {str(ve)}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id, "解析劳动俱乐部修课进度失败,请稍后重试"
|
||||
)
|
||||
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"获取劳动俱乐部修课进度异常: {str(he)}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取劳动俱乐部修课进度异常,请稍后重试"
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取劳动俱乐部修课进度未知异常: {str(e)}")
|
||||
return ProtectRouterErrorToCode().unknown_error.to_json_response(
|
||||
conn.logger.trace_id, "获取劳动俱乐部修课进度未知异常,请稍后重试"
|
||||
)
|
||||
|
||||
|
||||
@ldjlb_labor_router.get(
|
||||
"/joined/activities",
|
||||
response_model=UniResponseModel[LDJLBActivityListResponse],
|
||||
summary="获取已加入的劳动活动列表",
|
||||
)
|
||||
async def get_joined_activities(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
headers: Headers = Depends(get_ldjlb_header),
|
||||
) -> UniResponseModel[LDJLBActivityListResponse] | JSONResponse:
|
||||
"""
|
||||
获取用户已加入的劳动活动列表
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取用户已报名的所有劳动活动
|
||||
- 包含活动状态、时间、负责人等详细信息
|
||||
- 支持分页查询
|
||||
|
||||
💡 使用场景:
|
||||
- 查看我的劳动活动页面
|
||||
- 了解已报名活动的详细信息
|
||||
- 查看活动进度和状态
|
||||
|
||||
Returns:
|
||||
LDJLBActivityListResponse: 包含活动列表和分页信息
|
||||
"""
|
||||
try:
|
||||
conn.logger.info("开始获取已加入的劳动活动列表")
|
||||
response = await conn.client.post(
|
||||
url=LDJLBConfig().to_full_url(ENDPOINT["joined_activities"]),
|
||||
data={},
|
||||
headers=headers,
|
||||
timeout=6000,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取已加入的劳动活动列表失败,HTTP状态码: {response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取已加入的劳动活动列表失败,请稍后重试"
|
||||
)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
conn.logger.error(
|
||||
f"获取已加入的劳动活动列表失败,响应代码: {data.get('code')}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取已加入的劳动活动列表失败,请稍后重试"
|
||||
)
|
||||
try:
|
||||
activity_list = LDJLBActivityListResponse.model_validate(data)
|
||||
conn.logger.info(
|
||||
f"成功获取已加入的劳动活动列表,共 {len(activity_list.activities)} 个活动"
|
||||
)
|
||||
return UniResponseModel[LDJLBActivityListResponse](
|
||||
success=True,
|
||||
data=activity_list,
|
||||
message="获取已加入的劳动活动列表成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"解析已加入的劳动活动列表失败: {str(ve)}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id, "解析已加入的劳动活动列表失败,请稍后重试"
|
||||
)
|
||||
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"获取已加入的劳动活动列表异常: {str(he)}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取已加入的劳动活动列表异常,请稍后重试"
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取已加入的劳动活动列表未知异常: {str(e)}")
|
||||
return ProtectRouterErrorToCode().unknown_error.to_json_response(
|
||||
conn.logger.trace_id, "获取已加入的劳动活动列表未知异常,请稍后重试"
|
||||
)
|
||||
|
||||
|
||||
@ldjlb_labor_router.get(
|
||||
"/joined/clubs",
|
||||
response_model=UniResponseModel[LDJLBClubListResponse],
|
||||
summary="获取已加入的劳动俱乐部列表",
|
||||
)
|
||||
async def get_joined_clubs(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
headers: Headers = Depends(get_ldjlb_header),
|
||||
) -> UniResponseModel[LDJLBClubListResponse] | JSONResponse:
|
||||
"""
|
||||
获取用户已加入的劳动俱乐部列表
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取用户已加入的所有劳动俱乐部
|
||||
- 包含俱乐部详细信息、负责人、成员数等
|
||||
- 用于后续查询俱乐部活动
|
||||
|
||||
💡 使用场景:
|
||||
- 查看我的俱乐部页面
|
||||
- 获取俱乐部ID用于查询活动
|
||||
- 了解俱乐部详细信息
|
||||
|
||||
Returns:
|
||||
LDJLBClubListResponse: 包含俱乐部列表
|
||||
"""
|
||||
try:
|
||||
conn.logger.info("开始获取已加入的劳动俱乐部列表")
|
||||
response = await conn.client.post(
|
||||
url=LDJLBConfig().to_full_url(ENDPOINT["joined_clubs"]),
|
||||
data={},
|
||||
headers=headers,
|
||||
timeout=6000,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取已加入的劳动俱乐部列表失败,HTTP状态码: {response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取已加入的劳动俱乐部列表失败,请稍后重试"
|
||||
)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
conn.logger.error(
|
||||
f"获取已加入的劳动俱乐部列表失败,响应代码: {data.get('code')}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取已加入的劳动俱乐部列表失败,请稍后重试"
|
||||
)
|
||||
try:
|
||||
club_list = LDJLBClubListResponse.model_validate(data)
|
||||
conn.logger.info(
|
||||
f"成功获取已加入的劳动俱乐部列表,共 {len(club_list.clubs)} 个俱乐部"
|
||||
)
|
||||
return UniResponseModel[LDJLBClubListResponse](
|
||||
success=True,
|
||||
data=club_list,
|
||||
message="获取已加入的劳动俱乐部列表成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"解析已加入的劳动俱乐部列表失败: {str(ve)}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id, "解析已加入的劳动俱乐部列表失败,请稍后重试"
|
||||
)
|
||||
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"获取已加入的劳动俱乐部列表异常: {str(he)}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取已加入的劳动俱乐部列表异常,请稍后重试"
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取已加入的劳动俱乐部列表未知异常: {str(e)}")
|
||||
return ProtectRouterErrorToCode().unknown_error.to_json_response(
|
||||
conn.logger.trace_id, "获取已加入的劳动俱乐部列表未知异常,请稍后重试"
|
||||
)
|
||||
|
||||
|
||||
@ldjlb_labor_router.get(
|
||||
"/club/{club_id}/activities",
|
||||
response_model=UniResponseModel[LDJLBActivityListResponse],
|
||||
summary="获取指定俱乐部的活动列表",
|
||||
)
|
||||
async def get_club_activities(
|
||||
club_id: str,
|
||||
page_index: int = 1,
|
||||
page_size: int = 100,
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
headers: Headers = Depends(get_ldjlb_header),
|
||||
) -> UniResponseModel[LDJLBActivityListResponse] | JSONResponse:
|
||||
"""
|
||||
获取指定俱乐部的活动列表
|
||||
|
||||
✅ 功能特性:
|
||||
- 根据俱乐部ID获取该俱乐部的所有活动
|
||||
- 支持分页查询(默认pageSize=100)
|
||||
- 包含活动的详细信息和报名状态
|
||||
|
||||
💡 使用场景:
|
||||
- 浏览某个俱乐部的活动列表
|
||||
- 查找可报名的劳动活动
|
||||
- 了解活动详情准备报名
|
||||
|
||||
Args:
|
||||
club_id: 俱乐部ID
|
||||
page_index: 页码,默认1
|
||||
page_size: 每页大小,默认100
|
||||
|
||||
Returns:
|
||||
LDJLBActivityListResponse: 包含活动列表和分页信息
|
||||
"""
|
||||
try:
|
||||
conn.logger.info(f"开始获取俱乐部 {club_id} 的活动列表")
|
||||
response = await conn.client.post(
|
||||
url=LDJLBConfig().to_full_url(ENDPOINT["club_activities"])
|
||||
+ f"?pageIndex={page_index}&pageSize={page_size}&clubID={club_id}",
|
||||
data={},
|
||||
headers=headers,
|
||||
timeout=6000,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取俱乐部活动列表失败,HTTP状态码: {response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取俱乐部活动列表失败,请稍后重试"
|
||||
)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
conn.logger.error(f"获取俱乐部活动列表失败,响应代码: {data.get('code')}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取俱乐部活动列表失败,请稍后重试"
|
||||
)
|
||||
try:
|
||||
activity_list = LDJLBActivityListResponse.model_validate(data)
|
||||
conn.logger.info(
|
||||
f"成功获取俱乐部 {club_id} 的活动列表,共 {len(activity_list.activities)} 个活动"
|
||||
)
|
||||
return UniResponseModel[LDJLBActivityListResponse](
|
||||
success=True,
|
||||
data=activity_list,
|
||||
message="获取俱乐部活动列表成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"解析俱乐部活动列表失败: {str(ve)}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id, "解析俱乐部活动列表失败,请稍后重试"
|
||||
)
|
||||
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"获取俱乐部活动列表异常: {str(he)}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取俱乐部活动列表异常,请稍后重试"
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取俱乐部活动列表未知异常: {str(e)}")
|
||||
return ProtectRouterErrorToCode().unknown_error.to_json_response(
|
||||
conn.logger.trace_id, "获取俱乐部活动列表未知异常,请稍后重试"
|
||||
)
|
||||
|
||||
|
||||
@ldjlb_labor_router.post(
|
||||
"/activity/{activity_id}/apply",
|
||||
response_model=UniResponseModel[LDJLBApplyResponse],
|
||||
summary="报名参加劳动活动",
|
||||
)
|
||||
async def apply_activity(
|
||||
activity_id: str,
|
||||
reason: str = "加入课程",
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
headers: Headers = Depends(get_ldjlb_header),
|
||||
) -> UniResponseModel[LDJLBApplyResponse] | JSONResponse:
|
||||
"""
|
||||
报名参加劳动活动
|
||||
|
||||
✅ 功能特性:
|
||||
- 报名参加指定的劳动活动
|
||||
- 自动提交报名申请
|
||||
- 返回报名结果
|
||||
|
||||
💡 使用场景:
|
||||
- 用户点击报名按钮
|
||||
- 批量报名多个活动
|
||||
- 自动化报名流程
|
||||
|
||||
Args:
|
||||
activity_id: 活动ID
|
||||
reason: 报名理由,默认"加入课程"
|
||||
|
||||
Returns:
|
||||
LDJLBApplyResponse: 包含报名结果代码和消息
|
||||
"""
|
||||
try:
|
||||
conn.logger.info(f"开始报名活动 {activity_id}")
|
||||
response = await conn.client.post(
|
||||
url=LDJLBConfig().to_full_url(ENDPOINT["apply_join"]),
|
||||
data={"activityID": activity_id, "reason": reason},
|
||||
headers=headers,
|
||||
timeout=6000,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(f"报名活动失败,HTTP状态码: {response.status_code}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "报名活动失败,请稍后重试"
|
||||
)
|
||||
data = response.json()
|
||||
try:
|
||||
apply_result = LDJLBApplyResponse.model_validate(data)
|
||||
if apply_result.code == 0:
|
||||
conn.logger.success(f"成功报名活动 {activity_id}: {apply_result.msg}")
|
||||
else:
|
||||
conn.logger.warning(
|
||||
f"报名活动 {activity_id} 失败: {apply_result.msg} (code: {apply_result.code})"
|
||||
)
|
||||
return UniResponseModel[LDJLBApplyResponse](
|
||||
success=apply_result.code == 0,
|
||||
data=apply_result,
|
||||
message=apply_result.msg,
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"解析报名响应失败: {str(ve)}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id, "解析报名响应失败,请稍后重试"
|
||||
)
|
||||
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"报名活动异常: {str(he)}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "报名活动异常,请稍后重试"
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"报名活动未知异常: {str(e)}")
|
||||
return ProtectRouterErrorToCode().unknown_error.to_json_response(
|
||||
conn.logger.trace_id, "报名活动未知异常,请稍后重试"
|
||||
)
|
||||
|
||||
|
||||
@ldjlb_labor_router.post(
|
||||
"/scan_sign",
|
||||
response_model=UniResponseModel[ScanSignResponse],
|
||||
summary="扫码签到",
|
||||
)
|
||||
async def scan_sign_in(
|
||||
request: ScanSignRequest,
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
headers: Headers = Depends(get_ldjlb_header),
|
||||
) -> UniResponseModel[ScanSignResponse] | JSONResponse:
|
||||
"""
|
||||
扫码签到功能
|
||||
|
||||
✅ 功能特性:
|
||||
- 通过扫描二维码进行活动签到
|
||||
- 支持位置信息验证
|
||||
- 实时反馈签到结果
|
||||
|
||||
Args:
|
||||
request: 扫码签到请求,包含:
|
||||
- content: 扫描的二维码内容
|
||||
- location: 位置信息,格式为"经度,纬度"
|
||||
|
||||
Returns:
|
||||
UniResponseModel[ScanSignResponse]: 包含签到结果
|
||||
"""
|
||||
try:
|
||||
conn.logger.info(f"开始扫码签到,位置: {request.location}")
|
||||
|
||||
# 发送POST请求到劳动俱乐部签到接口
|
||||
response = await conn.client.post(
|
||||
url=LDJLBConfig().to_full_url(ENDPOINT["scan_sign"]),
|
||||
json={
|
||||
"content": request.content,
|
||||
"location": request.location,
|
||||
},
|
||||
headers=headers,
|
||||
timeout=6000,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(f"扫码签到失败,HTTP状态码: {response.status_code}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "扫码签到失败,请稍后重试"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
try:
|
||||
sign_result = ScanSignResponse.model_validate(data)
|
||||
|
||||
if sign_result.code == 0:
|
||||
conn.logger.success(f"扫码签到成功: {sign_result.msg}")
|
||||
else:
|
||||
conn.logger.warning(
|
||||
f"扫码签到失败: {sign_result.msg} (code: {sign_result.code})"
|
||||
)
|
||||
|
||||
return UniResponseModel[ScanSignResponse](
|
||||
success=sign_result.code == 0,
|
||||
data=sign_result,
|
||||
message=sign_result.msg or "签到完成",
|
||||
error=None,
|
||||
)
|
||||
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"解析签到响应失败: {str(ve)}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id, "解析签到响应失败,请稍后重试"
|
||||
)
|
||||
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"扫码签到异常: {str(he)}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "扫码签到异常,请稍后重试"
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"扫码签到未知异常: {str(e)}")
|
||||
return ProtectRouterErrorToCode().unknown_error.to_json_response(
|
||||
conn.logger.trace_id, "扫码签到未知异常,请稍后重试"
|
||||
)
|
||||
|
||||
|
||||
@ldjlb_labor_router.get(
|
||||
"/{activity_id}/sign_list",
|
||||
response_model=UniResponseModel[SignListResponse],
|
||||
summary="获取活动签到列表",
|
||||
)
|
||||
async def get_sign_list(
|
||||
activity_id: str,
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
headers: Headers = Depends(get_ldjlb_header),
|
||||
) -> UniResponseModel[SignListResponse] | JSONResponse:
|
||||
"""
|
||||
获取指定活动的签到列表
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取活动的所有签到项
|
||||
- 支持分页查询
|
||||
- 查看签到状态和时间
|
||||
- 辅助扫码签到功能
|
||||
|
||||
Args:
|
||||
activity_id: 活动ID
|
||||
sign_type: 签到类型,默认1(签到)
|
||||
page_index: 页码,从1开始
|
||||
page_size: 每页大小,默认10
|
||||
|
||||
Returns:
|
||||
UniResponseModel[SignListResponse]: 包含签到列表数据
|
||||
"""
|
||||
sign_type: int = 1
|
||||
page_index: int = 1
|
||||
page_size: int = 10
|
||||
try:
|
||||
conn.logger.info(f"开始获取活动 {activity_id} 的签到列表")
|
||||
|
||||
# 发送POST请求到劳动俱乐部签到列表接口
|
||||
response = await conn.client.post(
|
||||
url=LDJLBConfig().to_full_url(ENDPOINT["sign_list"]),
|
||||
data={
|
||||
"activityID": activity_id,
|
||||
"type": sign_type,
|
||||
"pageIndex": page_index,
|
||||
"pageSize": page_size,
|
||||
},
|
||||
headers=headers,
|
||||
timeout=6000,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(f"获取签到列表失败,HTTP状态码: {response.status_code}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取签到列表失败,请稍后重试"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
try:
|
||||
sign_list_result = SignListResponse.model_validate(data)
|
||||
|
||||
if sign_list_result.code == 0:
|
||||
sign_count = len(sign_list_result.data)
|
||||
signed_count = sum(1 for item in sign_list_result.data if item.is_sign)
|
||||
conn.logger.success(
|
||||
f"成功获取签到列表,共 {sign_count} 项,已签到 {signed_count} 项"
|
||||
)
|
||||
else:
|
||||
conn.logger.warning(f"获取签到列表失败 (code: {sign_list_result.code})")
|
||||
|
||||
return UniResponseModel[SignListResponse](
|
||||
success=sign_list_result.code == 0,
|
||||
data=sign_list_result,
|
||||
message="获取签到列表成功"
|
||||
if sign_list_result.code == 0
|
||||
else "获取签到列表失败",
|
||||
error=None,
|
||||
)
|
||||
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"解析签到列表响应失败: {str(ve)}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id, "解析签到列表响应失败,请稍后重试"
|
||||
)
|
||||
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"获取签到列表异常: {str(he)}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取签到列表异常,请稍后重试"
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取签到列表未知异常: {str(e)}")
|
||||
return ProtectRouterErrorToCode().unknown_error.to_json_response(
|
||||
conn.logger.trace_id, "获取签到列表未知异常,请稍后重试"
|
||||
)
|
||||
|
||||
|
||||
@ldjlb_labor_router.get(
|
||||
"/{activity_id}/detail",
|
||||
response_model=UniResponseModel[ActivityDetailResponse],
|
||||
summary="获取活动详情",
|
||||
)
|
||||
async def get_activity_detail(
|
||||
activity_id: str,
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
headers: Headers = Depends(get_ldjlb_header),
|
||||
) -> UniResponseModel[ActivityDetailResponse] | JSONResponse:
|
||||
"""
|
||||
获取活动详细信息
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取活动完整信息(标题、时间、地点等)
|
||||
- 查看活动地址和教室信息
|
||||
- 查看报名人数和限制
|
||||
- 查看审批流程和教师列表
|
||||
- 支持扫码签到功能的前置查询
|
||||
|
||||
Args:
|
||||
activity_id: 活动ID
|
||||
|
||||
Returns:
|
||||
UniResponseModel[ActivityDetailResponse]: 包含活动详细信息
|
||||
|
||||
说明:
|
||||
- formData 中包含"活动地址"等关键信息(如教室位置)
|
||||
- flowData 包含审批流程记录
|
||||
- teacherList 包含活动相关教师信息
|
||||
"""
|
||||
try:
|
||||
conn.logger.info(f"开始获取活动详情: {activity_id}")
|
||||
|
||||
# 发送POST请求到劳动俱乐部活动详情接口
|
||||
response = await conn.client.post(
|
||||
url=LDJLBConfig().to_full_url(ENDPOINT["activity_detail"]),
|
||||
data={"id": activity_id},
|
||||
headers=headers,
|
||||
timeout=6000,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(f"获取活动详情失败,HTTP状态码: {response.status_code}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取活动详情失败,请稍后重试"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
try:
|
||||
detail_result = ActivityDetailResponse.model_validate(data)
|
||||
|
||||
if detail_result.code == 0 and detail_result.data:
|
||||
# 提取关键信息用于日志
|
||||
activity_title = detail_result.data.title
|
||||
activity_location = "未知"
|
||||
|
||||
# 从 formData 中提取活动地址
|
||||
for field in detail_result.form_data:
|
||||
if field.name == "活动地址" and field.value:
|
||||
activity_location = field.value
|
||||
break
|
||||
|
||||
conn.logger.success(
|
||||
f"成功获取活动详情 - 标题: {activity_title}, 地点: {activity_location}"
|
||||
)
|
||||
else:
|
||||
conn.logger.warning(f"获取活动详情失败 (code: {detail_result.code})")
|
||||
|
||||
return UniResponseModel[ActivityDetailResponse](
|
||||
success=detail_result.code == 0,
|
||||
data=detail_result,
|
||||
message="获取活动详情成功"
|
||||
if detail_result.code == 0
|
||||
else "获取活动详情失败",
|
||||
error=None,
|
||||
)
|
||||
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"解析活动详情响应失败: {str(ve)}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id, "解析活动详情响应失败,请稍后重试"
|
||||
)
|
||||
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"获取活动详情异常: {str(he)}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取活动详情异常,请稍后重试"
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取活动详情未知异常: {str(e)}")
|
||||
return ProtectRouterErrorToCode().unknown_error.to_json_response(
|
||||
conn.logger.trace_id, "获取活动详情未知异常,请稍后重试"
|
||||
)
|
||||
1
loveace/router/endpoint/ldjlb/model/__init__.py
Normal file
1
loveace/router/endpoint/ldjlb/model/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 劳动俱乐部数据模型
|
||||
22
loveace/router/endpoint/ldjlb/model/base.py
Normal file
22
loveace/router/endpoint/ldjlb/model/base.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
|
||||
from loveace.config.manager import config_manager
|
||||
|
||||
settings = config_manager.get_settings()
|
||||
|
||||
|
||||
class LDJLBConfig:
|
||||
"""劳动俱乐部模块配置常量"""
|
||||
|
||||
BASE_URL = "http://api-ldjlb-ac-acxk-net.vpn2.aufe.edu.cn:8118"
|
||||
WEB_URL = "http://ldjlb-ac-acxk-net.vpn2.aufe.edu.cn:8118"
|
||||
LOGIN_SERVICE_URL = "http://uaap-aufe-edu-cn.vpn2.aufe.edu.cn:8118/cas/login?service=http%3a%2f%2fapi.ldjlb.ac.acxk.net%2fUser%2fIndex%2fCoreLoginCallback%3fisCASGateway%3dtrue"
|
||||
RSA_PRIVATE_KEY_PATH = str(
|
||||
Path(settings.app.rsa_protect_key_path).joinpath("aac_private_key.pem")
|
||||
)
|
||||
|
||||
def to_full_url(self, path: str) -> str:
|
||||
"""将路径转换为完整URL"""
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
return path
|
||||
return self.BASE_URL.rstrip("/") + "/" + path.lstrip("/")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user