⚒️ 重大重构 LoveACE V2

引入了 mongodb
对数据库进行了一定程度的数据加密
性能改善
代码简化
统一错误模型和响应
使用 apifox 作为文档
This commit is contained in:
2025-11-20 20:44:25 +08:00
parent 6b90c6d7bb
commit bbc86b8330
168 changed files with 14264 additions and 19152 deletions

View File

@@ -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
View File

@@ -1,4 +1,4 @@
# LoveAC Project .gitattributes
# LoveACE Project .gitattributes
# 语言检测和统计配置
# ==============================================

View File

@@ -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
View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1 @@
3.12

View File

@@ -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
View File

@@ -1,159 +1,115 @@
# LoveACE - 财大教务自动化工具
# LoveACE - 财大自动化工具
<div align="center">
<img src="logo.jpg" alt="LoveAC Logo" width="120" height="120" />
**简化学生教务操作,提高使用效率**
[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Python Version](https://img.shields.io/badge/python-3.12-blue.svg)](https://python.org)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-green.svg)](https://fastapi.tiangolo.com)
[![Documentation](https://img.shields.io/badge/docs-VitePress-brightgreen.svg)]
<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">

View File

@@ -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"
]

View File

@@ -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

View File

@@ -1,4 +0,0 @@
from .creator import db_manager, get_db_session
from .base import Base
__all__ = ["db_manager", "get_db_session", "Base"]

View File

@@ -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

View File

@@ -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())
# 注释:电费记录和充值记录都实时获取,不存储在数据库中

View File

View File

@@ -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())

View File

@@ -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>

View File

@@ -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
}
})

View File

@@ -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;
}
}

View File

@@ -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)
}
}

View 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: 返回值说明
"""
```

View File

@@ -1,326 +0,0 @@
# ISIM 电费查询系统 API 文档
## 概述
ISIMIntegrated 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

View File

@@ -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>

View File

@@ -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进行需要管理员权限

View File

@@ -1,5 +0,0 @@
# 贡献指南
感谢您对LoveACE项目的关注我们欢迎所有形式的贡献包括但不限于代码贡献、文档改进、问题报告和功能建议。
## In Progress

View File

@@ -1,5 +0,0 @@
# 部署指南
本指南介绍如何在生产环境中部署LoveACE教务系统自动化工具。
## In Progress

View File

@@ -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
**请注意**: 本免责声明可能会不定期更新,继续使用本软件即表示您接受更新后的条款。

View File

@@ -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
```

View File

@@ -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

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
View 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
View 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()

View File

@@ -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()

View File

@@ -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
}
}

View 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())

View 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)

View 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)

View 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())

View 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
View 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()

View 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())

View File

@@ -0,0 +1 @@
# 劳动俱乐部数据库模型

View 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())

View 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())

View 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())

View 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

View 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",
]

View 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)

View 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)

View 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)

View 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, "获取爱安财分数明细未知异常,请稍后重试"
)

View 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("/")

View 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="该类别下的分数明细列表"
)

View 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

View 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/")

View 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)

View 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,
)

View 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)

View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel, Field
class AuthMeResponse(BaseModel):
success: bool = Field(..., description="是否验证成功")
userid: str = Field(..., description="用户ID")

View 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="服务器错误",
)

View 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="服务器错误",
)
##############################################################

View 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)

View 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)

View 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
)

View 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="充值记录")

View 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="房间缓存已过期,请稍后重新获取房间列表",
)

View 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="数据更新时间")

View 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)

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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)

View 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
)

View 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
)

View 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
)

View 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="是否可以选课")

View 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("/")

View 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="学分汇总详情")

View 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="座位号")

View File

@@ -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'&nbsp;', ' ', clean_name).strip()
clean_name = re.sub(r"<[^>]*>", "", name)
clean_name = re.sub(r"&nbsp;", " ", 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'&nbsp;', ' ', clean_name).strip()
clean_name = re.sub(r"<[^>]*>", "", name)
clean_name = re.sub(r"&nbsp;", " ", 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="错误数据")

View File

@@ -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="学期ID2024-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")

View 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="成绩记录列表")

View 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="星期几")

View 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
)

View 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,
)

View 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
)

View 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
)

View 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

View 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

View 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

View 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

View 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

View 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)

View 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, "获取活动详情未知异常,请稍后重试"
)

View File

@@ -0,0 +1 @@
# 劳动俱乐部数据模型

View 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