🎉初次提交

This commit is contained in:
2025-08-03 16:50:56 +08:00
commit 56bdf5388d
67 changed files with 18379 additions and 0 deletions

10
.editorconfig Normal file
View File

@@ -0,0 +1,10 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
[*.{js,json,yml}]
charset = utf-8
indent_style = space
indent_size = 2

121
.gitattributes vendored Normal file
View File

@@ -0,0 +1,121 @@
# LoveAC Project .gitattributes
# 语言检测和统计配置
# ==============================================
# 语言检测配置
# ==============================================
# 主要编程语言
*.py linguist-language=Python
*.ts linguist-language=TypeScript
*.js linguist-language=JavaScript
*.vue linguist-language=Vue
# 配置和数据文件
*.json linguist-language=JSON
*.yaml linguist-language=YAML
*.yml linguist-language=YAML
*.toml linguist-language=TOML
*.ini linguist-language=INI
# 文档文件
*.md linguist-documentation
*.rst linguist-documentation
*.txt linguist-documentation
# 忽略自动生成的文件
pdm.lock linguist-generated
yarn.lock linguist-generated
package-lock.json linguist-generated
.pnp.cjs linguist-generated
.pnp.loader.mjs linguist-generated
# 忽略第三方和依赖文件
node_modules/ linguist-vendored
__pycache__/ linguist-generated
.venv/ linguist-vendored
venv/ linguist-vendored
.idea/ linguist-vendored
.vscode/ linguist-vendored
# 忽略构建输出
dist/ linguist-generated
build/ linguist-generated
docs/.vitepress/dist/ linguist-generated
# 忽略日志和缓存
*.log linguist-generated
logs/ linguist-generated
.cache/ linguist-generated
# 忽略自动生成的API文档
docs/api/ linguist-generated=true
openapi.json linguist-generated
# ==============================================
# 行结束符配置
# ==============================================
# 默认行为:自动检测,签出时转换为平台默认
* text=auto
# 强制LF行结束符的文件
*.py text eol=lf
*.js text eol=lf
*.ts text eol=lf
*.vue text eol=lf
*.json text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.md text eol=lf
*.txt text eol=lf
*.sh text eol=lf
# 强制CRLF行结束符的文件
*.bat text eol=crlf
*.cmd text eol=crlf
# 二进制文件
*.jpg binary
*.jpeg binary
*.png binary
*.gif binary
*.ico binary
*.pdf binary
*.zip binary
*.tar.gz binary
*.db binary
*.sqlite binary
*.sqlite3 binary
# ==============================================
# 差异查看配置
# ==============================================
# 图片文件使用外部工具查看差异
*.png diff=exif
*.jpg diff=exif
*.jpeg diff=exif
*.gif diff=exif
# 文档文件的差异配置
*.md diff=markdown
*.rst diff=markdown
# ==============================================
# 合并配置
# ==============================================
# 配置文件冲突时使用union合并策略
*.md merge=union
CHANGELOG.md merge=union
README.md merge=union
# ==============================================
# 过滤器配置
# ==============================================
# 敏感信息过滤
config.json filter=remove-secrets
*.key filter=remove-secrets
*.pem filter=remove-secrets

109
.github/workflows/deploy-docs.yml vendored Normal file
View File

@@ -0,0 +1,109 @@
name: 部署文档
on:
push:
branches:
- main
paths:
- 'docs/**'
- 'openapi.json'
- 'package.json'
- 'yarn.lock'
- '.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:
# 验证和检查作业
validate:
runs-on: ubuntu-latest
steps:
- name: 检出代码
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: 设置Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'yarn'
- name: 启用Yarn
run: corepack enable
- name: 安装依赖
run: yarn install --frozen-lockfile
- name: 验证OpenAPI规范
run: yarn swagger:validate
- name: 检查Markdown文档
run: yarn lint:docs
continue-on-error: true
# 构建作业
build:
runs-on: ubuntu-latest
needs: validate
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
steps:
- name: 检出代码
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 设置Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'yarn'
- name: 启用Yarn
run: corepack enable
- name: 安装依赖
run: yarn install --frozen-lockfile
- name: 复制OpenAPI文件到public目录
run: |
mkdir -p docs/public
cp openapi.json docs/public/
- name: 构建文档
run: yarn 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

422
.gitignore vendored Normal file
View File

@@ -0,0 +1,422 @@
# =====================================================
# LoveAC Project .gitignore
# =====================================================
# ===== 敏感信息和配置文件 =====
# 配置文件(包含数据库密码等敏感信息)
config.json
config_local.json
config_prod.json
config_dev.json
.env
.env.*
!.env.example
# 密钥和证书文件
*.key
*.pem
*.p12
*.pfx
*.crt
*.cer
secrets/
keys/
# ===== 日志文件 =====
# 所有日志文件
logs/
*.log
*.log.*
log/
log_*
# ===== 数据库文件 =====
# SQLite 数据库
*.db
*.sqlite
*.sqlite3
# 数据库备份
*.sql
*.dump
backup/
backups/
# ===== 用户数据和上传文件 =====
# 用户上传的文件
data/
uploads/
media/
static/uploads/
user_data/
# 文档和视频文件
*.pdf
*.doc
*.docx
*.mp4
*.avi
*.mov
# ===== Python 相关 =====
# 字节码文件
__pycache__/
*.py[cod]
*$py.class
*.so
# 分发/打包
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# 单元测试/覆盖率报告
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# 虚拟环境
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# ===== IDE 和编辑器 =====
# VSCode
.vscode/
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# PyCharm
.idea/
*.swp
*.swo
# Vim
*~
.*.swp
.*.swo
# Emacs
.#*
# Sublime Text
*.sublime-project
*.sublime-workspace
# ===== 系统文件 =====
# macOS
.DS_Store
.AppleDouble
.LSOverride
Icon?
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Windows
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
*.stackdump
[Dd]esktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msix
*.msm
*.msp
*.lnk
# Linux
*~
.fuse_hidden*
.directory
.Trash-*
.nfs*
# ===== 临时文件和缓存 =====
# 临时文件
*.tmp
*.temp
*.swp
*.swo
temp/
tmp/
.cache/
# 缓存文件
cache/
.cache/
*.cache
# ===== 开发工具 =====
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# 包管理
# pdm
.pdm.toml
__pypackages__/
# Celery
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# ===== 部署相关 =====
# Docker
.dockerignore
Dockerfile.local
docker-compose.override.yml
# Kubernetes
*.yaml.local
*.yml.local
# Terraform
*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl
# ===== 其他 =====
# 压缩文件
*.zip
*.tar.gz
*.rar
*.7z
# 备份文件
*.bak
*.backup
*.old
*~
# 测试数据
test_data/
mock_data/
sample_data/
# 性能分析
*.prof
*.pstats
# 安全扫描报告
security_report.*
vulnerability_report.*
# ===== Node.js 相关 =====
# npm
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Yarn v2+
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage
.grunt
# Bower dependency directory
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons
build/Release
# Dependency directories
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Storybook build outputs
.out
.storybook-out
storybook-static
# Rollup.js default build output
dist/
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# VitePress specific
docs/.vitepress/dist
docs/.vitepress/cache
.temp
# ===== 文档相关 =====
# 自动生成的API文档
docs/api/*.md
!docs/api/index.md
# VitePress构建输出
docs/.vitepress/dist/
docs/.vitepress/cache/
docs/.vitepress/public/
# ===== 语言统计和分析 =====
# GitHub Linguist (注意:不要忽略.gitattributes文件本身)
# Language statistics
.linguist-*
language-stats.json
languages.json
# Code analysis
.codeclimate.yml
.codacy.yml
sonar-project.properties
.sonarqube/
# Dependency analysis
dependency-check-report.*
.dependency-check/
# License scan
license-report.*
.license-report/
# Security analysis
.snyk
.github/dependabot.yml

31
.markdownlint.json Normal file
View File

@@ -0,0 +1,31 @@
{
"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*[:=]"
}
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
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:
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.

193
README.md Normal file
View File

@@ -0,0 +1,193 @@
# 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)]
</div>
## 🚀 项目简介
LoveACE 是一个面向安徽财经大学的教务系统自动化工具专为安徽财经大学教务OA系统设计。通过RESTful API接口提供自动评教(开发中)、课表查询、成绩查询等功能,大幅简化学生的教务操作流程。
### ✨ 主要特性
- **🔐 安全认证**: 基于邀请码的用户注册系统,确保使用安全
- **📚 教务集成**: 深度集成教务系统,支持学业信息、培养方案查询
- **⭐ 智能评教**: 全自动评教系统,支持任务管理和进度监控
- **💯 积分查询**: 爱安财系统集成,实时查询积分和明细
- **🚀 高性能**: 基于FastAPI构建支持异步处理和高并发
- **📖 完整文档**: 提供详细的API文档和部署指南
### 🛠️ 技术栈
- **后端框架**: [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/) - 现代化的文档生成工具
## 📦 快速开始
### 前置条件
- **Python 3.12+**
- **PDM**
- **MySQL** 数据库
### 安装部署
```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
## 📚 文档
### 在线文档
访问我们的在线文档获取完整指南:**https://LoveACE-team.github.io/LoveACE**
### 文档内容
- **📖 快速开始**: 安装和基本使用指南
- **⚙️ 配置指南**: 详细的配置选项说明
- **🚀 部署指南**: 生产环境部署教程
- **📡 API文档**: 交互式API文档 (基于OpenAPI)
- **🤝 贡献指南**: 如何参与项目开发
- **⚖️ 免责声明**: 使用须知和免责条款
### 本地构建文档
```bash
# 安装文档依赖
yarn install
# 启动开发服务器
yarn docs:dev
# 构建静态文档
yarn docs:build
```
## 🏗️ 项目结构
```
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 # 项目依赖配置
```
## 🔧 配置说明
### 数据库配置
```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)。
### 贡献方式
- 🐛 **Bug报告**: [创建Issue](https://github.com/LoveACE-Team/LoveACE/issues/new)
- 💡 **功能建议**: [发起Issue](https://github.com/LoveACE-Team/LoveACE/issues/new)
- 📝 **代码贡献**: 提交Pull Request
- 📖 **文档改进**: 帮助完善文档
## ⚖️ 免责声明
**重要提醒**: 本软件仅供学习和个人使用,请在使用前仔细阅读 [免责声明](https://LoveACE-team.github.io/LoveACE/disclaimer)。
- ✅ 本软件为教育目的开发的开源项目
- ⚠️ 使用时请遵守学校相关规定和法律法规
- 🛡️ 请妥善保管个人账户信息
- ❌ 不得用于任何商业用途
## 📞 支持与联系
- 📧 **邮箱**: [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) 开源。
---
<div align="center">
**如果这个项目对你有帮助,请给它一个 ⭐️**
Made with ❤️ by [Sibuxiangx](https://github.com/Sibuxiangx)
</div>

12
config/__init__.py Normal file
View File

@@ -0,0 +1,12 @@
from .manager import config_manager, Settings
from .models import DatabaseConfig, AUFEConfig, S3Config, LogConfig, AppConfig
__all__ = [
"config_manager",
"Settings",
"DatabaseConfig",
"AUFEConfig",
"S3Config",
"LogConfig",
"AppConfig"
]

68
config/logger.py Normal file
View File

@@ -0,0 +1,68 @@
import sys
from pathlib import Path
from richuru import install
from loguru import logger
from typing import Any, Dict
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

177
config/manager.py Normal file
View File

@@ -0,0 +1,177 @@
import json
import os
from pathlib import Path
from typing import Any, Dict, Optional
from loguru import logger
from pydantic import ValidationError
from .models 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:
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():
logger.warning(f"配置文件 {self.config_file} 不存在,将创建默认配置")
settings = self._create_default_config()
self._save_config(settings)
return settings
try:
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
except ValidationError as e:
logger.error(f"配置文件验证失败: {e}")
raise
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:
# 支持嵌套键,如 'database.url'
keys = key.split('.')
current = config_dict
for k in keys[:-1]:
if k not in current:
current[k] = {}
current = current[k]
current[keys[-1]] = value
else:
config_dict[key] = value
try:
# 验证更新后的配置
new_settings = Settings(**config_dict)
self._save_config(new_settings)
self._settings = new_settings
logger.info("配置更新成功")
return new_settings
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():
try:
log_dir.mkdir(parents=True, exist_ok=True)
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'
]
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
if current_key in sensitive_keys:
if isinstance(value, str) and value:
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()

151
config/models.py Normal file
View File

@@ -0,0 +1,151 @@
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field, field_validator
from enum import Enum
class LogLevel(str, Enum):
"""日志级别枚举"""
TRACE = "TRACE"
DEBUG = "DEBUG"
INFO = "INFO"
SUCCESS = "SUCCESS"
WARNING = "WARNING"
ERROR = "ERROR"
CRITICAL = "CRITICAL"
class DatabaseConfig(BaseModel):
"""数据库配置"""
url: str = Field(
default="mysql+aiomysql://root:123456@localhost:3306/loveac",
description="数据库连接URL"
)
echo: bool = Field(default=False, description="是否启用SQL日志")
pool_size: int = Field(default=10, description="连接池大小")
max_overflow: int = Field(default=20, description="连接池最大溢出")
pool_timeout: int = Field(default=30, description="连接池超时时间(秒)")
pool_recycle: int = Field(default=3600, 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="最大重连次数")
activity_timeout: int = Field(default=300, description="活动超时时间(秒)")
monitor_interval: int = Field(default=60, description="监控间隔(秒)")
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="重试指数基数")
# UAAP配置
uaap_base_url: str = Field(
default="http://uaap-aufe-edu-cn.vpn2.aufe.edu.cn:8118/cas",
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"
)
# 默认请求头
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="默认请求头"
)
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")
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')
@classmethod
def validate_required_fields(cls, v):
"""验证必填字段"""
# 允许为空,但应在运行时检查
return v
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="日志保留时间")
compression: str = Field(default="zip", description="日志压缩格式")
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="应用描述")
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来源"
)
cors_allow_credentials: bool = Field(default=True, description="是否允许CORS凭据")
cors_allow_methods: List[str] = Field(
default_factory=lambda: ["*"],
description="允许的CORS方法"
)
cors_allow_headers: List[str] = Field(
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="工作进程数")
class Settings(BaseModel):
"""主配置类"""
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
aufe: AUFEConfig = Field(default_factory=AUFEConfig)
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
}

4
database/__init__.py Normal file
View File

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

6
database/base.py Normal file
View File

@@ -0,0 +1,6 @@
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase
class Base(AsyncAttrs, DeclarativeBase):
pass

79
database/creator.py Normal file
View File

@@ -0,0 +1,79 @@
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(f"请启动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

0
database/train_plan.py Normal file
View File

52
database/user.py Normal file
View File

@@ -0,0 +1,52 @@
import datetime
from typing import Optional
from sqlalchemy import func, String, Text
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

@@ -0,0 +1,200 @@
<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>

75
docs/.vitepress/config.ts Normal file
View File

@@ -0,0 +1,75 @@
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

@@ -0,0 +1,29 @@
/* 自定义样式文件 */
/* 确保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

@@ -0,0 +1,17 @@
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)
}
}

18
docs/api/index.md Normal file
View File

@@ -0,0 +1,18 @@
---
layout: page
title: LoveACE API 文档
description: 基于 OpenAPI 3.1 规范的交互式 API 文档
---
<script setup>
import { onMounted } from 'vue'
onMounted(() => {
// 为当前页面添加特殊的CSS类用于样式定制
document.body.classList.add('api-page')
})
</script>
<div class="api-docs-container">
<SwaggerUI />
</div>

232
docs/config.md Normal file
View File

@@ -0,0 +1,232 @@
# 配置指南
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进行需要管理员权限

5
docs/contributing.md Normal file
View File

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

5
docs/deploy.md Normal file
View File

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

119
docs/disclaimer.md Normal file
View File

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

123
docs/getting-started.md Normal file
View File

@@ -0,0 +1,123 @@
# 快速开始
本指南将帮助您快速设置并运行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 --reload
```
服务启动后,您可以访问:
- **API服务**: http://localhost:8000
- **API文档**: http://localhost:8000/docs
- **Redoc文档**: http://localhost:8000/redoc
## 验证安装
访问健康检查接口验证服务是否正常运行:
```bash
curl http://localhost:8000/health
```
如果一切正常,您应该看到类似以下的响应:
```json
{
"code": 200,
"message": "服务运行正常",
"data": {
"status": "healthy",
"timestamp": "2024-01-01T12:00:00Z"
}
}
```
## 下一步
- 查看 [配置指南](/config) 了解详细配置选项
- 阅读 [API文档](/api/) 了解可用接口
- 参考 [部署指南](/deploy) 进行生产环境部署
## 常见问题
### 数据库连接失败
检查`config.json`中的数据库配置是否正确,确保:
- 数据库服务已启动
- 用户名密码正确
- 网络连接正常
### 端口被占用
如果8000端口被占用可以在配置文件中修改端口
```json
{
"app": {
"port": 8080
}
}
```
### 依赖安装失败
确保使用Python 3.12,并尝试清理缓存:
```bash
pdm cache clear
pdm install
```

91
docs/index.md Normal file
View File

@@ -0,0 +1,91 @@
---
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) 文件。

BIN
docs/public/images/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

2758
docs/public/openapi.json generated Normal file

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

BIN
logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

70
main.py Normal file
View File

@@ -0,0 +1,70 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from database.creator import db_manager
from router.invite import invite_router
from router.jwc import jwc_router
from router.login import login_router
from router.aac import aac_router
from router.user import user_router
from richuru import install
from fastapi.middleware.cors import CORSMiddleware as allow_origins
import uvicorn
# 导入配置管理器和日志设置
from config import config_manager
from config.logger import setup_logger, get_logger
# 初始化日志系统
setup_logger()
logger = get_logger()
install()
@asynccontextmanager
async def lifespan(app: FastAPI):
# 验证配置文件完整性
if not config_manager.validate_config():
logger.error("配置文件验证失败,请检查配置")
raise RuntimeError("配置文件验证失败")
logger.info("应用程序启动中...")
# 启动时连接数据库
await db_manager.init_db()
logger.success("数据库连接成功")
yield
# 关闭时断开数据库连接
await db_manager.close_db()
logger.info("应用程序已关闭")
# 获取应用配置
app_config = config_manager.get_settings().app
# Production FastAPI application
app = FastAPI(
lifespan=lifespan,
title=app_config.title,
description=app_config.description,
version=app_config.version,
debug=app_config.debug,
)
# CORS配置
app.add_middleware(
allow_origins,
allow_origins=app_config.cors_allow_origins,
allow_credentials=app_config.cors_allow_credentials,
allow_methods=app_config.cors_allow_methods,
allow_headers=app_config.cors_allow_headers,
)
app.include_router(invite_router)
app.include_router(jwc_router)
app.include_router(login_router)
app.include_router(aac_router)
app.include_router(user_router)
if __name__ == "__main__":
uvicorn.run(app, host=app_config.host, port=app_config.port)

2758
openapi.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"packageManager": "yarn@4.6.0",
"name": "loveac-docs",
"version": "1.0.0",
"description": "LoveAC项目文档",
"scripts": {
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
"docs:serve": "vitepress serve docs",
"swagger:validate": "npx swagger-parser validate openapi.json",
"lint:docs": "markdownlint docs/**/*.md --ignore docs/.vitepress --ignore docs/public"
},
"devDependencies": {
"@apidevtools/swagger-parser": "^10.1.0",
"markdownlint-cli": "^0.39.0",
"vitepress": "^1.6.3"
},
"type": "module"
}

1103
pdm.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,287 @@
from typing import Optional
from urllib.parse import unquote
from loguru import logger
from provider.aufe.aac.model import (
LoveACScoreInfo,
LoveACScoreInfoResponse,
LoveACScoreListResponse,
SimpleResponse,
ErrorLoveACScoreInfo,
ErrorLoveACScoreInfoResponse,
ErrorLoveACScoreListResponse,
ErrorLoveACScoreCategory,
)
from provider.aufe.client import (
AUFEConnection,
AUFEConfig,
activity_tracker,
retry_async,
AUFEConnectionError,
AUFELoginError,
AUFEParseError,
RetryConfig
)
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"
@retry_async()
async def get_system_token(vpn_connection: AUFEConnection) -> Optional[str]:
"""
获取系统令牌 (sys_token)
Args:
vpn_connection: VPN连接实例
Returns:
Optional[str]: 系统令牌失败时返回None
Raises:
AUFEConnectionError: 连接失败
AUFEParseError: 令牌解析失败
"""
try:
next_location = AACConfig.LOGIN_SERVICE_URL
max_redirects = 10 # 防止无限重定向
redirect_count = 0
while redirect_count < max_redirects:
response = await vpn_connection.requester().get(
next_location, follow_redirects=False
)
# 如果是重定向,继续跟踪
if response.status_code in (301, 302, 303, 307, 308):
next_location = response.headers.get("Location")
if not next_location:
raise AUFEParseError("重定向响应中缺少Location头")
logger.debug(f"重定向到: {next_location}")
redirect_count += 1
if "register?ticket=" in next_location:
logger.info(f"重定向到爱安财注册页面: {next_location}")
try:
sys_token = next_location.split("ticket=")[-1]
# URL编码转为正常字符串
sys_token = unquote(sys_token)
if sys_token:
logger.info(f"获取到系统令牌: {sys_token[:10]}...")
return sys_token
else:
raise AUFEParseError("提取的系统令牌为空")
except Exception as e:
raise AUFEParseError(f"解析系统令牌失败: {str(e)}") from e
else:
break
if redirect_count >= max_redirects:
raise AUFEConnectionError(f"重定向次数过多 ({max_redirects})")
raise AUFEParseError("未能从重定向中获取到系统令牌")
except (AUFEConnectionError, AUFEParseError):
raise
except Exception as e:
logger.error(f"获取系统令牌异常: {str(e)}")
raise AUFEConnectionError(f"获取系统令牌失败: {str(e)}") from e
class AACClient:
"""爱安财系统客户端"""
def __init__(
self,
vpn_connection: AUFEConnection,
ticket: Optional[str] = None,
retry_config: Optional[RetryConfig] = None
):
"""
初始化爱安财系统客户端
Args:
vpn_connection: VPN连接实例
ticket: 系统令牌
retry_config: 重试配置
"""
self.vpn_connection = vpn_connection
self.base_url = AACConfig.BASE_URL.rstrip("/")
self.web_url = AACConfig.WEB_URL.rstrip("/")
self.twfid = vpn_connection.get_twfid()
self.system_token: Optional[str] = ticket
self.retry_config = retry_config or RetryConfig()
logger.info(
f"爱安财系统客户端初始化: base_url={self.base_url}, web_url={self.web_url}"
)
def _get_default_headers(self) -> dict:
"""获取默认请求头"""
return {
**AUFEConfig.DEFAULT_HEADERS,
"ticket": self.system_token or "",
"sdp-app-session": self.twfid or "",
}
@activity_tracker
@retry_async()
async def validate_connection(self) -> bool:
"""
验证爱安财系统连接
Returns:
bool: 连接是否有效
Raises:
AUFEConnectionError: 连接失败
"""
try:
headers = AUFEConfig.DEFAULT_HEADERS.copy()
response = await self.vpn_connection.requester().get(
f"{self.web_url}/", headers=headers
)
is_valid = response.status_code == 200
logger.info(
f"爱安财系统连接验证结果: {'有效' if is_valid else '无效'} (HTTP状态码: {response.status_code})"
)
if not is_valid:
raise AUFEConnectionError(f"爱安财系统连接验证失败,状态码: {response.status_code}")
return is_valid
except AUFEConnectionError:
raise
except Exception as e:
logger.error(f"验证爱安财系统连接异常: {str(e)}")
raise AUFEConnectionError(f"验证连接失败: {str(e)}") from e
@activity_tracker
async def fetch_score_info(self) -> LoveACScoreInfo:
"""
获取爱安财总分信息,使用重试机制
Returns:
LoveACScoreInfo: 总分信息,失败时返回错误模型
"""
try:
logger.info("开始获取爱安财总分信息")
headers = self._get_default_headers()
# 使用新的重试机制
score_response = await self.vpn_connection.model_request(
model=LoveACScoreInfoResponse,
url=f"{self.base_url}/User/Center/DoGetScoreInfo?sf_request_type=ajax",
method="POST",
headers=headers,
data={}, # 空的POST请求体
follow_redirects=True,
)
if score_response and score_response.code == 0 and score_response.data:
logger.info(
f"爱安财总分信息获取成功: {score_response.data.total_score}"
)
return score_response.data
else:
error_msg = score_response.msg if score_response else '未知错误'
logger.error(f"获取爱安财总分信息失败: {error_msg}")
# 返回错误模型
return ErrorLoveACScoreInfo(
TotalScore=-1.0,
IsTypeAdopt=False,
TypeAdoptResult=f"请求失败: {error_msg}",
)
except (AUFEConnectionError, AUFEParseError) as e:
logger.error(f"获取爱安财总分信息失败: {str(e)}")
return ErrorLoveACScoreInfo(
TotalScore=-1.0,
IsTypeAdopt=False,
TypeAdoptResult=f"请求失败: {str(e)}",
)
except Exception as e:
logger.error(f"获取爱安财总分信息异常: {str(e)}")
# 返回错误模型
return ErrorLoveACScoreInfo(
TotalScore=-1.0,
IsTypeAdopt=False,
TypeAdoptResult="系统错误,请稍后重试",
)
@activity_tracker
async def fetch_score_list(
self, page_index: int = 1, page_size: int = 10
) -> LoveACScoreListResponse:
"""
获取爱安财分数列表,使用重试机制
Args:
page_index: 页码默认为1
page_size: 每页大小默认为10
Returns:
LoveACScoreListResponse: 分数列表响应,失败时返回错误模型
"""
def _create_error_response(error_msg: str) -> ErrorLoveACScoreListResponse:
"""创建错误响应模型"""
return ErrorLoveACScoreListResponse(
code=-1,
msg=error_msg,
data=[
ErrorLoveACScoreCategory(
ID="error",
ShowNum=-1,
TypeName="请求失败",
TotalScore=-1.0,
children=[],
)
],
)
try:
logger.info(
f"开始获取爱安财分数列表,页码: {page_index}, 每页大小: {page_size}"
)
headers = self._get_default_headers()
data = {"pageIndex": str(page_index), "pageSize": str(page_size)}
# 使用新的重试机制
score_list_response = await self.vpn_connection.model_request(
model=LoveACScoreListResponse,
url=f"{self.base_url}/User/Center/DoGetScoreList?sf_request_type=ajax",
method="POST",
headers=headers,
data=data,
follow_redirects=True,
)
if (
score_list_response
and score_list_response.code == 0
and score_list_response.data
):
logger.info(
f"爱安财分数列表获取成功,分类数量: {len(score_list_response.data)}"
)
return score_list_response
else:
error_msg = score_list_response.msg if score_list_response else '未知错误'
logger.error(f"获取爱安财分数列表失败: {error_msg}")
return _create_error_response(f"请求失败: {error_msg}")
except (AUFEConnectionError, AUFEParseError) as e:
logger.error(f"获取爱安财分数列表失败: {str(e)}")
return _create_error_response(f"请求失败: {str(e)}")
except Exception as e:
logger.error(f"获取爱安财分数列表异常: {str(e)}")
return _create_error_response("系统错误,已进行多次重试")

View File

@@ -0,0 +1,66 @@
from fastapi import Depends, HTTPException
from loguru import logger
from provider.loveac.authme import fetch_user_by_token
from provider.aufe.aac import AACClient, get_system_token
from provider.aufe.client import AUFEConnection
from database.user import User, AACTicket
from sqlalchemy.ext.asyncio import AsyncSession
from database.creator import get_db_session
from sqlalchemy import select
async def get_aac_client(
user: User = Depends(fetch_user_by_token),
db: AsyncSession = Depends(get_db_session),
) -> AACClient:
"""
获取AAC客户端
:param user: 用户信息
:return: AACClient
:raises HTTPException: 如果用户无效或登录失败
"""
if not user:
raise HTTPException(status_code=400, detail="无效的令牌或用户不存在")
aufe = AUFEConnection.create_or_get_connection("vpn.aufe.edu.cn", user.userid)
if not aufe.login_status():
userid = user.userid
easyconnect_password = user.easyconnect_password
if not await aufe.login(userid, easyconnect_password):
raise HTTPException(
status_code=400,
detail="VPN登录失败请检查用户名和密码",
)
if not aufe.uaap_login_status():
userid = user.userid
password = user.password
if not await aufe.uaap_login(userid, password):
raise HTTPException(
status_code=400,
detail="大学登录失败,请检查用户名和密码",
)
# 检查AAC Ticket是否存在
async with db as session:
result = await session.execute(
select(AACTicket).where(AACTicket.userid == user.userid)
)
aac_ticket = result.scalars().first()
if not aac_ticket:
# 如果不存在尝试获取新的AAC Ticket
logger.info(f"用户 {user.userid} 的 AAC Ticket 不存在,正在获取新的 Ticket")
aac_ticket = await get_system_token(aufe)
if not aac_ticket:
logger.error(f"用户 {user.userid} 获取 AAC Ticket 失败")
raise HTTPException(
status_code=400,
detail="获取AAC Ticket失败请稍后再试",
)
# 保存到数据库
async with db as session:
session.add(AACTicket(userid=user.userid, aac_token=aac_ticket))
await session.commit()
logger.success(f"用户 {user.userid} 成功获取并保存新的 AAC Ticket")
else:
logger.info(f"用户 {user.userid} 使用现有的 AAC Ticket")
aac_ticket = aac_ticket.aac_token
return AACClient(aufe, aac_ticket)

105
provider/aufe/aac/model.py Normal file
View File

@@ -0,0 +1,105 @@
from typing import List, Optional, Any
from pydantic import BaseModel, Field
class LoveACScoreInfo(BaseModel):
"""爱安财总分信息"""
total_score: float = Field(0.0, alias="TotalScore")
is_type_adopt: bool = Field(False, alias="IsTypeAdopt")
type_adopt_result: str = Field("", alias="TypeAdoptResult")
class LoveACScoreItem(BaseModel):
"""爱安财分数明细条目"""
id: str = Field("", alias="ID")
title: str = Field("", alias="Title")
type_name: str = Field("", alias="TypeName")
user_no: str = Field("", alias="UserNo")
score: float = Field(0.0, alias="Score")
add_time: str = Field("", alias="AddTime")
class LoveACScoreCategory(BaseModel):
"""爱安财分数类别"""
id: str = Field("", alias="ID")
show_num: int = Field(0, alias="ShowNum")
type_name: str = Field("", alias="TypeName")
total_score: float = Field(0.0, alias="TotalScore")
children: List[LoveACScoreItem] = Field([], alias="children")
class LoveACBaseResponse(BaseModel):
"""爱安财系统响应基础模型"""
code: int = 0
msg: str = ""
data: Any = None
class LoveACScoreInfoResponse(LoveACBaseResponse):
"""爱安财总分响应"""
data: Optional[LoveACScoreInfo] = None
class LoveACScoreListResponse(LoveACBaseResponse):
"""爱安财分数列表响应"""
data: Optional[List[LoveACScoreCategory]] = None
class SimpleResponse(BaseModel):
"""简单响应类用于解析基本的JSON结构"""
code: int = 0
msg: str = ""
data: Any = None
class ErrorLoveACScoreInfo(LoveACScoreInfo):
"""错误的爱安财总分信息模型,用于重试失败时返回"""
total_score: float = Field(-1.0, alias="TotalScore")
is_type_adopt: bool = Field(False, alias="IsTypeAdopt")
type_adopt_result: str = Field("请求失败,请稍后重试", alias="TypeAdoptResult")
class ErrorLoveACScoreCategory(BaseModel):
"""错误的爱安财分数类别模型"""
id: str = Field("error", alias="ID")
show_num: int = Field(-1, alias="ShowNum")
type_name: str = Field("请求失败", alias="TypeName")
total_score: float = Field(-1.0, alias="TotalScore")
children: List[LoveACScoreItem] = Field([], alias="children")
class ErrorLoveACBaseResponse(BaseModel):
"""错误的爱安财系统响应基础模型"""
code: int = -1
msg: str = "网络请求失败,已进行多次重试"
data: Any = None
class ErrorLoveACScoreInfoResponse(ErrorLoveACBaseResponse):
"""错误的爱安财总分响应"""
data: Optional[ErrorLoveACScoreInfo] = ErrorLoveACScoreInfo(
TotalScore=-1.0, IsTypeAdopt=False, TypeAdoptResult="请求失败,请稍后重试"
)
class ErrorLoveACScoreListResponse(LoveACScoreListResponse):
"""错误的爱安财分数列表响应"""
code: int = -1
msg: str = "网络请求失败,已进行多次重试"
data: Optional[List[ErrorLoveACScoreCategory]] = [
ErrorLoveACScoreCategory(
ID="error", ShowNum=-1, TypeName="请求失败", TotalScore=-1.0, children=[]
)
]

1176
provider/aufe/client.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
from fastapi import Depends, HTTPException
from provider.loveac.authme import fetch_user_by_token
from provider.aufe.jwc import JWCClient
from provider.aufe.client import AUFEConnection
from database.user import User
async def get_jwc_client(
user: User = Depends(fetch_user_by_token),
) -> JWCClient:
"""
获取教务处客户端
:param authme_request: AuthmeRequest
:return: JWCClient
"""
if not user:
raise HTTPException(status_code=400, detail="无效的令牌或用户不存在")
aufe = AUFEConnection.create_or_get_connection("vpn.aufe.edu.cn", user.userid)
if not aufe.login_status():
userid = user.userid
easyconnect_password = user.easyconnect_password
if not await aufe.login(userid, easyconnect_password):
raise HTTPException(
status_code=400,
detail="VPN登录失败请检查用户名和密码",
)
if not aufe.uaap_login_status():
userid = user.userid
password = user.password
if not await aufe.uaap_login(userid, password):
raise HTTPException(
status_code=400,
detail="大学登录失败,请检查用户名和密码",
)
return JWCClient(aufe)

296
provider/aufe/jwc/model.py Normal file
View File

@@ -0,0 +1,296 @@
from typing import Dict, List, Optional, Any
from pydantic import BaseModel, Field
class AcademicDataItem(BaseModel):
"""学术信息数据项用于直接反序列化JSON数组中的元素"""
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")
class AcademicInfo(BaseModel):
"""学术信息数据模型 - 兼容旧版API"""
completed_courses: int = Field(0, alias="count")
failed_courses: int = Field(0, alias="countNotPass")
gpa: float = Field(0, alias="gpa")
# ==================== 学期和成绩相关模型 ====================
class TermInfo(BaseModel):
"""学期信息模型"""
term_id: str = Field("", description="学期ID2024-2025-2-1")
term_name: str = Field("", description="学期名称2024-2025春季学期")
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: str = Field("", description="课程性质")
exam_type: str = Field("", description="考试性质")
score: str = Field("", description="成绩")
retake_score: Optional[str] = Field(None, description="重修成绩")
makeup_score: Optional[str] = Field(None, description="补考成绩")
class TermScoreResponse(BaseModel):
"""学期成绩响应模型"""
page_size: int = Field(50, description="每页大小")
page_num: int = Field(1, description="页码")
total_count: int = Field(0, description="总记录数")
records: List[ScoreRecord] = Field(default_factory=list, description="成绩记录列表")
# ==================== 原有模型继续 ====================
class TrainingPlanDataItem(BaseModel):
"""培养方案数据项"""
plan_name: str = "" # 第一项为培养方案名称
plan_id: str = "" # 第二项为培养方案ID
class TrainingPlanResponseWrapper(BaseModel):
"""培养方案响应模型"""
count: int = 0
data: List[List[str]] = []
class TrainingPlanInfo(BaseModel):
"""培养方案信息模型 - 兼容旧版API"""
plan_name: str = Field("", alias="pyfa")
current_term: str = Field("", alias="term")
pending_courses: int = Field(0, alias="courseCount")
major_name: str = Field("", alias="major")
grade: str = Field("", alias="grade")
class CourseSelectionStatusDirectResponse(BaseModel):
"""选课状态响应模型新格式"""
term_name: str = Field("", alias="zxjxjhm")
status_code: str = Field("", alias="retString")
class CourseSelectionStatus(BaseModel):
"""选课状态信息"""
can_select: bool = Field(False, alias="isCanSelect")
start_time: str = Field("", alias="startTime")
end_time: str = Field("", alias="endTime")
class CourseId(BaseModel):
"""课程ID信息"""
evaluated_people: str = Field("", alias="evaluatedPeople")
coure_sequence_number: str = Field("", alias="coureSequenceNumber")
evaluation_content_number: str = Field("", alias="evaluationContentNumber")
class Questionnaire(BaseModel):
"""问卷信息"""
questionnaire_number: str = Field("", alias="questionnaireNumber")
questionnaire_name: str = Field("", alias="questionnaireName")
class Course(BaseModel):
"""课程基本信息"""
id: Optional[CourseId] = None
questionnaire: Optional[Questionnaire] = Field(None, alias="questionnaire")
evaluated_people: str = Field("", alias="evaluatedPeople")
is_evaluated: str = Field("", alias="isEvaluated")
evaluation_content: str = Field("", alias="evaluationContent")
class CourseListResponse(BaseModel):
"""课程列表响应"""
not_finished_num: int = Field(0, alias="notFinishedNum")
evaluation_num: int = Field(0, alias="evaluationNum")
data: List[Course] = Field(default_factory=list, alias="data")
msg: str = Field("", alias="msg")
result: str = "success" # 设置默认值
class EvaluationResponse(BaseModel):
"""评价提交响应"""
result: str = ""
msg: str = ""
data: Any = None
class EvaluationRequestParam(BaseModel):
"""评价请求参数"""
opt_type: str = "submit"
token_value: str = ""
questionnaire_code: str = ""
evaluation_content: str = ""
evaluated_people_number: str = ""
count: str = ""
zgpj: str = ""
rating_items: Dict[str, str] = {}
def to_form_data(self) -> Dict[str, str]:
"""将对象转换为表单数据映射"""
form_data = {
"optType": self.opt_type,
"tokenValue": self.token_value,
"questionnaireCode": self.questionnaire_code,
"evaluationContent": self.evaluation_content,
"evaluatedPeopleNumber": self.evaluated_people_number,
"count": self.count,
"zgpj": self.zgpj,
}
# 添加评分项
form_data.update(self.rating_items)
return form_data
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: List[OtherExamRecord] = Field(default_factory=list, alias="records")
class UnifiedExamInfo(BaseModel):
"""统一考试信息模型 - 对外提供的统一格式"""
course_name: str = "" # 课程名称
exam_date: str = "" # 考试日期 (YYYY-MM-DD)
exam_time: str = "" # 考试时间
exam_location: str = "" # 考试地点
exam_type: str = "" # 考试类型 (校统考/其他考试)
note: str = "" # 备注信息
class ExamInfoResponse(BaseModel):
"""考试信息统一响应模型"""
exams: List[UnifiedExamInfo] = Field(default_factory=list)
total_count: int = 0
# ==================== 错误响应模型 ====================
class ErrorAcademicInfo(AcademicInfo):
"""错误的学术信息数据模型"""
completed_courses: int = Field(-1, alias="count")
failed_courses: int = Field(-1, alias="countNotPass")
gpa: float = Field(-1.0, alias="gpa")
class ErrorTrainingPlanInfo(TrainingPlanInfo):
"""错误的培养方案信息模型"""
plan_name: str = Field("请求失败,请稍后重试", alias="pyfa")
current_term: str = Field("", alias="term")
pending_courses: int = Field(-1, alias="courseCount")
major_name: str = Field("请求失败", alias="major")
grade: str = Field("", alias="grade")
class ErrorCourseSelectionStatus(CourseSelectionStatus):
"""错误的选课状态信息"""
can_select: bool = Field(False, alias="isCanSelect")
start_time: str = Field("请求失败", alias="startTime")
end_time: str = Field("请求失败", alias="endTime")
class ErrorCourse(Course):
"""错误的课程基本信息"""
id: Optional[CourseId] = None
questionnaire: Optional[Questionnaire] = None
evaluated_people: str = Field("请求失败", alias="evaluatedPeople")
is_evaluated: str = Field("", alias="isEvaluated")
evaluation_content: str = Field("请求失败,请稍后重试", alias="evaluationContent")
class ErrorCourseListResponse(CourseListResponse):
"""错误的课程列表响应"""
not_finished_num: int = Field(-1, alias="notFinishedNum")
evaluation_num: int = Field(-1, alias="evaluationNum")
data: List[Course] = Field(default_factory=list, alias="data")
msg: str = Field("网络请求失败,已进行多次重试", alias="msg")
result: str = "failed"
class ErrorEvaluationResponse(EvaluationResponse):
"""错误的评价提交响应"""
result: str = "failed"
msg: str = "网络请求失败,已进行多次重试"
data: Any = None
class ErrorExamInfoResponse(ExamInfoResponse):
"""错误的考试信息响应模型"""
exams: List[UnifiedExamInfo] = Field(default_factory=list)
total_count: int = -1
class ErrorTermScoreResponse(BaseModel):
"""错误的学期成绩响应模型"""
page_size: int = Field(-1, description="每页大小")
page_num: int = Field(-1, description="页码")
total_count: int = Field(-1, description="总记录数")
records: List[ScoreRecord] = Field(default_factory=list, description="成绩记录列表")

View File

View File

View File

89
provider/loveac/authme.py Normal file
View File

@@ -0,0 +1,89 @@
import json
import uuid
from fastapi import Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from database.creator import get_db_session
from database.user import User, AuthME
from sqlalchemy import select, desc
from pydantic import BaseModel
from loguru import logger
from typing import Optional
class AuthmeRequest(BaseModel):
token: str
class AuthmeResponse(BaseModel):
code: int
message: str
async def fetch_user_by_token(
AuthmeRequest: AuthmeRequest,
asyncsession: AsyncSession = Depends(get_db_session)
) -> User:
"""
根据令牌获取用户信息
:param AuthmeRequest: 包含token的请求对象
:param asyncsession: 数据库会话
:return: User
"""
async with asyncsession as session:
# 根据token查找AuthME记录
result = await session.execute(
select(AuthME).where(AuthME.authme_token == AuthmeRequest.token)
)
authme = result.scalars().first()
if not authme:
raise HTTPException(status_code=401, detail="无效的令牌或用户不存在")
# 根据userid获取用户信息
user_result = await session.execute(
select(User).where(User.userid == authme.userid)
)
user = user_result.scalars().first()
if not user:
raise HTTPException(status_code=401, detail="用户不存在")
logger.info(f"User {user.userid} fetched successfully using token.")
return user
async def manage_user_tokens(userid: str, new_token: str, device_id: str, session: AsyncSession) -> None:
"""
管理用户token每个用户最多保持5个设备会话超出时删除最旧的2个
:param userid: 用户ID
:param new_token: 新的token
:param device_id: 设备标识符
:param session: 数据库会话
"""
# 检查当前用户的token数量
result = await session.execute(
select(AuthME)
.where(AuthME.userid == userid)
.order_by(desc(AuthME.create_date))
)
existing_tokens = result.scalars().all()
# 如果超过4个token即将添加第6个删除最旧的2个
if len(existing_tokens) >= 5:
# 删除最旧的2个token
oldest_tokens = existing_tokens[-2:]
for token_record in oldest_tokens:
await session.delete(token_record)
# 添加新的token记录
new_authme = AuthME(
userid=userid,
authme_token=new_token,
device_id=device_id
)
session.add(new_authme)
await session.commit()
def generate_device_id() -> str:
"""生成设备标识符"""
return str(uuid.uuid4())

View File

33
pyproject.toml Normal file
View File

@@ -0,0 +1,33 @@
[project]
name = "LoveACE"
version = "0.0.1"
description = "NOOOOOOOOOOO"
authors = [
{name = "Sibuxiangx", email = "sibuxiang@proton.me"},
]
dependencies = [
"fastapi>=0.115.12",
"uvicorn>=0.34.2",
"httpx>=0.28.1",
"cryptography>=45.0.3",
"rich>=14.0.0",
"richuru>=0.1.1",
"aiomysql>=0.2.0",
"sqlalchemy[asyncio]>=2.0.41",
"aiosqlite>=0.21.0",
"bs4>=0.0.2",
"aiofiles>=24.1.0",
"textual>=5.2.0",
"aioboto3>=15.0.0",
]
requires-python = "==3.12.*"
readme = "README.md"
license = {text = "MIT"}
[project.optional-dependencies]
dev = [
"black>=25.1.0",
]
[tool.pdm]
distribution = false

77
router/aac/__init__.py Normal file
View File

@@ -0,0 +1,77 @@
from fastapi import Depends
from fastapi.routing import APIRouter
from provider.aufe.aac import AACClient
from provider.aufe.aac.depends import get_aac_client
from provider.loveac.authme import AuthmeResponse
from router.aac.model import ScoreInfoResponse, ScoreListResponse
from router.common_model import ErrorResponse
aac_router = APIRouter(prefix="/api/v1/aac")
@aac_router.post(
"/fetch_score_info",
summary="获取爱安财总分信息",
response_model=ScoreInfoResponse | AuthmeResponse | ErrorResponse,
)
async def fetch_score_info(client: AACClient = Depends(get_aac_client)):
"""获取爱安财系统的总分信息"""
try:
result = await client.fetch_score_info()
# 检查是否是AuthmeResponse认证错误
if isinstance(result, AuthmeResponse):
return result
# 使用新的错误检测机制
response = ScoreInfoResponse.from_data(
data=result,
success_message="爱安财总分信息获取成功",
error_message="获取爱安财总分信息失败,网络请求多次重试后仍无法连接服务器,请稍后重试或联系管理员",
)
return response
except Exception as e:
return ErrorResponse(
message=f"获取爱安财总分信息时发生系统错误:{str(e)}", code=500
)
@aac_router.post(
"/fetch_score_list",
summary="获取爱安财分数明细列表",
response_model=ScoreListResponse | AuthmeResponse | ErrorResponse,
)
async def fetch_score_list(
client: AACClient = Depends(get_aac_client),
):
"""获取爱安财系统的分数明细列表"""
try:
result = await client.fetch_score_list()
# 检查是否是AuthmeResponse认证错误
if isinstance(result, AuthmeResponse):
return result
# 检查分数列表数据
if result and hasattr(result, "data") and result.data:
# 使用新的错误检测机制检查列表数据
response = ScoreListResponse.from_data(
data=result.data,
success_message="爱安财分数明细获取成功",
error_message="获取爱安财分数明细失败,网络请求多次重试后仍无法连接服务器,请稍后重试或联系管理员",
)
return response
else:
# 没有数据的情况
return ScoreListResponse.error(
message="暂无爱安财分数数据,请确认您的账户状态或稍后再试",
code=404,
data=[],
)
except Exception as e:
return ErrorResponse(
message=f"获取爱安财分数明细时发生系统错误:{str(e)}", code=500
)

16
router/aac/model.py Normal file
View File

@@ -0,0 +1,16 @@
from router.common_model import BaseResponse
from provider.aufe.aac.model import LoveACScoreInfo, LoveACScoreCategory
from typing import List
# 统一响应模型
class ScoreInfoResponse(BaseResponse[LoveACScoreInfo]):
"""爱安财总分信息响应"""
pass
class ScoreListResponse(BaseResponse[List[LoveACScoreCategory]]):
"""爱安财分数明细列表响应"""
pass

100
router/common_model.py Normal file
View File

@@ -0,0 +1,100 @@
from typing import Generic, Optional, TypeVar, Any
from pydantic import BaseModel, Field
T = TypeVar("T")
class BaseResponse(BaseModel, Generic[T]):
"""通用响应模型基类"""
code: int = Field(200, description="状态码")
message: str = Field("成功", description="提示信息")
data: Optional[T] = Field(None, description="响应数据")
@classmethod
def success(cls, data: T, message: str = "获取成功") -> "BaseResponse[T]":
"""创建成功响应"""
return cls(code=200, message=message, data=data)
@classmethod
def error(
cls, message: str = "请求失败", code: int = 500, data: Optional[T] = None
) -> "BaseResponse[T]":
"""创建错误响应"""
return cls(code=code, message=message, data=data)
@classmethod
def from_data(
cls,
data: Any,
success_message: str = "获取成功",
error_message: str = "网络请求失败,已进行多次重试",
) -> "BaseResponse[T]":
"""
根据数据自动判断是否为错误模型并生成相应响应
Args:
data: 要检查的数据
success_message: 成功时的消息
error_message: 失败时的消息
Returns:
BaseResponse: 相应的响应模型
"""
if cls._is_error_data(data):
return cls.error(message=error_message, code=500, data=data)
else:
return cls.success(data=data, message=success_message)
@staticmethod
def _is_error_data(data: Any) -> bool:
"""
检测数据是否为错误模型
Args:
data: 要检查的数据
Returns:
bool: 如果是错误数据返回True
"""
if data is None:
return True
# 检查是否有错误指示符
if hasattr(data, "total_score") and data.total_score == -1.0:
return True
if hasattr(data, "completed_courses") and data.completed_courses == -1:
return True
if hasattr(data, "gpa") and data.gpa == -1.0:
return True
if hasattr(data, "plan_name") and data.plan_name == "请求失败,请稍后重试":
return True
if hasattr(data, "code") and data.code == -1:
return True
if hasattr(data, "total_count") and data.total_count == -1:
return True
if hasattr(data, "result") and data.result == "failed":
return True
if (
hasattr(data, "can_select")
and hasattr(data, "start_time")
and data.start_time == "请求失败"
):
return True
# 检查列表类型的错误数据
if isinstance(data, list) and len(data) > 0:
first_item = data[0]
if hasattr(first_item, "id") and first_item.id == "error":
return True
if hasattr(first_item, "type_name") and first_item.type_name == "请求失败":
return True
return False
class ErrorResponse(BaseResponse[None]):
"""专用错误响应模型"""
def __init__(self, message: str = "请求失败,请稍后重试", code: int = 500):
super().__init__(code=code, message=message, data=None)

118
router/invite/__init__.py Normal file
View File

@@ -0,0 +1,118 @@
from fastapi import Depends
from fastapi.routing import APIRouter
from database.user import Invite, User
from database.creator import get_db_session
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from router.invite.model import (
InviteRequest,
RegisterRequest,
InviteResponse,
RegisterResponse,
InviteTokenData,
AuthMeData,
)
from provider.aufe.client import AUFEConnection
from database.user import AuthME
import secrets
invite_router = APIRouter(prefix="/api/v1/user")
invite_tokens = []
@invite_router.post("/veryfy_invite_code", summary="验证邀请码")
async def verify_invite_code(
data: InviteRequest,
asyncsession: AsyncSession = Depends(get_db_session),
) -> InviteResponse:
"""
验证邀请码
:param data: InviteRequest
:return: InviteResponse
"""
async with asyncsession as session:
invite_code = data.invite_code
invite = select(Invite).where(Invite.invite_code == invite_code)
result = await session.execute(invite)
invite_data = result.scalars().first()
if invite_data:
invite_token = secrets.token_urlsafe(128)
invite_tokens.append(invite_token)
return InviteResponse(
code=200,
message="邀请码验证成功",
data=InviteTokenData(invite_token=invite_token),
)
else:
return InviteResponse(
code=400,
message="邀请码无效或已过期",
data=None,
)
@invite_router.post("/register", summary="注册新用户")
async def register_user(
data: RegisterRequest,
asyncsession: AsyncSession = Depends(get_db_session),
) -> RegisterResponse:
"""
注册新用户
:param data: RegisterRequest
:return: RegisterResponse
"""
async with asyncsession as session:
userid = data.userid
password = data.password
easyconnect_password = data.easyconnect_password
invite_token = data.invite_token
if invite_token not in invite_tokens:
return RegisterResponse(
code=400,
message="无效的邀请令牌",
data=None,
)
# 检查用户是否已存在
existing_user = await session.execute(select(User).where(User.userid == userid))
if existing_user.scalars().first():
return RegisterResponse(
code=400,
message="用户已存在",
data=None,
)
# 检查连接
vpn = AUFEConnection.create_or_get_connection("vpn.aufe.edu.cn", userid)
if not await vpn.login(userid, easyconnect_password):
return RegisterResponse(
code=400,
message="VPN登录失败请检查用户名和密码",
data=None,
)
if not await vpn.uaap_login(userid, password):
return RegisterResponse(
code=400,
message="大学登录失败,请检查用户名和密码",
data=None,
)
# 创建新用户
new_user = User(
userid=userid,
password=password,
easyconnect_password=easyconnect_password,
)
session.add(new_user)
await session.commit()
authme_token = secrets.token_urlsafe(128)
new_authme = AuthME(userid=userid, authme_token=authme_token)
session.add(new_authme)
await session.commit()
invite_tokens.remove(invite_token)
return RegisterResponse(
code=200,
message="注册成功",
data=AuthMeData(authme_token=authme_token),
)

39
router/invite/model.py Normal file
View File

@@ -0,0 +1,39 @@
from pydantic import BaseModel, Field
from router.common_model import BaseResponse
class InviteRequest(BaseModel):
invite_code: str = Field(..., description="邀请码")
class RegisterRequest(BaseModel):
userid: str = Field(..., description="学号")
password: str = Field(..., description="密码")
easyconnect_password: str = Field(..., description="易联密码")
invite_token: str = Field(..., description="邀请码")
# 邀请相关响应数据模型
class InviteTokenData(BaseModel):
"""邀请令牌数据"""
invite_token: str = Field(..., description="邀请密钥")
class AuthMeData(BaseModel):
"""认证令牌数据"""
authme_token: str = Field(..., description="AuthMe Token")
# 统一响应模型
class InviteResponse(BaseResponse[InviteTokenData]):
"""邀请响应"""
pass
class RegisterResponse(BaseResponse[AuthMeData]):
"""注册响应"""
pass

605
router/jwc/__init__.py Normal file
View File

@@ -0,0 +1,605 @@
from fastapi import Depends
from fastapi.routing import APIRouter
from provider.aufe.jwc import JWCClient
from provider.aufe.jwc.depends import get_jwc_client
from provider.loveac.authme import AuthmeResponse
from router.jwc.model import (
AcademicInfoResponse,
TrainingPlanInfoResponse,
CourseListResponse,
ExamInfoAPIResponse,
AllTermsResponse,
TermScoreAPIResponse,
FetchTermScoreRequest,
ScheduleResponse,
FetchScheduleRequest,
)
from router.common_model import ErrorResponse
from .evaluate_model import (
EvaluationStatsResponse,
CurrentCourseInfoResponse,
TaskOperationResponse,
InitializeResponse,
CourseInfo,
TaskStatusEnum,
EvaluationStatsData,
CurrentCourseInfoData,
TaskOperationData,
InitializeData,
)
from .evaluate import (
get_task_manager,
remove_task_manager,
)
from datetime import datetime
from loguru import logger
jwc_router = APIRouter(prefix="/api/v1/jwc")
invite_tokens = []
@jwc_router.post(
"/fetch_academic_info",
summary="获取学业信息",
response_model=AcademicInfoResponse | AuthmeResponse | ErrorResponse,
)
async def fetch_academic_info(client: JWCClient = Depends(get_jwc_client)):
"""获取学术信息(课程数量、绩点等)"""
try:
result = await client.fetch_academic_info()
# 检查是否是AuthmeResponse认证错误
if isinstance(result, AuthmeResponse):
return result
# 使用新的错误检测机制
response = AcademicInfoResponse.from_data(
data=result,
success_message="学业信息获取成功",
error_message="获取学业信息失败,网络请求多次重试后仍无法连接教务系统,请稍后重试或联系管理员",
)
return response
except Exception as e:
return ErrorResponse(message=f"获取学业信息时发生系统错误:{str(e)}", code=500)
@jwc_router.post(
"/fetch_education_plan_info",
summary="获取培养方案信息",
response_model=TrainingPlanInfoResponse | AuthmeResponse | ErrorResponse,
)
async def fetch_education_plan_info(client: JWCClient = Depends(get_jwc_client)):
"""获取培养方案信息"""
try:
result = await client.fetch_training_plan_info()
# 检查是否是AuthmeResponse认证错误
if isinstance(result, AuthmeResponse):
return result
# 使用新的错误检测机制
response = TrainingPlanInfoResponse.from_data(
data=result,
success_message="培养方案信息获取成功",
error_message="获取培养方案信息失败,网络请求多次重试后仍无法连接教务系统,请稍后重试或联系管理员",
)
return response
except Exception as e:
return ErrorResponse(
message=f"获取培养方案信息时发生系统错误:{str(e)}", code=500
)
@jwc_router.post(
"/fetch_evaluation_course_list",
summary="获取评教课程列表",
response_model=CourseListResponse | AuthmeResponse | ErrorResponse,
)
async def fetch_evaluation_course_list(client: JWCClient = Depends(get_jwc_client)):
"""获取评教课程列表"""
try:
result = await client.fetch_evaluation_course_list()
# 检查是否是AuthmeResponse认证错误
if isinstance(result, AuthmeResponse):
return result
# 对于列表类型,使用特殊的检查逻辑
if result and len(result) > 0:
# 检查第一个元素是否是错误数据
first_course = result[0]
if (
hasattr(first_course, "evaluated_people")
and first_course.evaluated_people == "请求失败"
):
return CourseListResponse.error(
message="获取评教课程列表失败,网络请求多次重试后仍无法连接教务系统,请稍后重试或联系管理员",
code=500,
data=[],
)
else:
return CourseListResponse.success(
data=result, message="评教课程列表获取成功"
)
else:
return CourseListResponse.success(data=[], message="暂无需要评教的课程")
except Exception as e:
return ErrorResponse(
message=f"获取评教课程列表时发生系统错误:{str(e)}", code=500
)
@jwc_router.post(
"/fetch_exam_info",
summary="获取考试信息",
response_model=ExamInfoAPIResponse | AuthmeResponse | ErrorResponse,
)
async def fetch_exam_info(client: JWCClient = Depends(get_jwc_client)):
"""获取考试信息,包括校统考和其他考试"""
try:
train_plan_info = await client.fetch_training_plan_info()
# 检查培养方案信息是否获取失败
if not train_plan_info or (
hasattr(train_plan_info, "plan_name")
and train_plan_info.plan_name == "请求失败,请稍后重试"
):
return ErrorResponse(
message="无法获取培养方案信息,导致考试信息获取失败。网络请求多次重试后仍无法连接教务系统,请稍后重试或联系管理员",
code=500,
)
# 检查是否是AuthmeResponse
if isinstance(train_plan_info, AuthmeResponse):
return train_plan_info
_term_code = train_plan_info.current_term
# _term_code -> term_code: "2024-2025春季学期" 转换为 "2024-2025-2-1" "2024-2025秋季学期" 转换为 "2024-2025-1-1"
# 进行转换
term_code = f"{_term_code[:4]}-{_term_code[5:9]}-{"1" if _term_code[10] == "" else "2"}-1"
print(f"当前学期代码: {term_code}")
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,
)
result = await client.fetch_unified_exam_info(
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
term_code=term_code,
)
# 检查是否是AuthmeResponse认证错误
if isinstance(result, AuthmeResponse):
return result
# 使用新的错误检测机制
response = ExamInfoAPIResponse.from_data(
data=result,
success_message="考试信息获取成功",
error_message="获取考试信息失败,网络请求多次重试后仍无法连接教务系统,请稍后重试或联系管理员",
)
return response
except Exception as e:
return ErrorResponse(message=f"获取考试信息时发生系统错误:{str(e)}", code=500)
# ==================== 评价系统API ====================
@jwc_router.post(
"/evaluation/initialize",
summary="初始化评价任务",
response_model=InitializeResponse | AuthmeResponse,
)
async def initialize_evaluation_task(client: JWCClient = Depends(get_jwc_client)):
"""初始化评价任务,获取课程列表"""
try:
# 获取用户ID (从JWC客户端获取)
user_id = getattr(client, "user_id", "unknown")
# 检查是否已有活跃的任务管理器
existing_manager = get_task_manager(user_id)
if existing_manager:
current_status = existing_manager.get_task_status().status
if current_status in [
TaskStatusEnum.RUNNING,
TaskStatusEnum.PAUSED,
TaskStatusEnum.INITIALIZING,
]:
return InitializeResponse(
code=400,
message="您已有一个评价任务在进行中,请先完成或终止当前任务",
data=None,
)
# 如果任务已完成、失败或终止,移除旧的任务管理器
elif current_status in [
TaskStatusEnum.COMPLETED,
TaskStatusEnum.FAILED,
TaskStatusEnum.TERMINATED,
]:
remove_task_manager(user_id)
# 获取或创建任务管理器
task_manager = get_task_manager(user_id, client)
if not task_manager:
return InitializeResponse(code=400, message="创建任务管理器失败", data=None)
# 执行初始化
success = await task_manager.initialize()
stats = task_manager.get_task_status()
# 转换课程列表格式
course_list = []
for course in stats.course_list:
course_info = CourseInfo(
course_id=(
getattr(course.id, "coure_sequence_number", "") if course.id else ""
),
course_name=course.evaluation_content,
teacher_name=course.evaluated_people,
is_evaluated=course.is_evaluated,
evaluation_content=course.evaluation_content,
)
course_list.append(course_info)
initialize_data = InitializeData(
total_courses=stats.total_courses,
pending_courses=stats.pending_courses,
course_list=course_list,
)
return InitializeResponse(
code=200 if success else 400, message=stats.message, data=initialize_data
)
except Exception as e:
return InitializeResponse(code=500, message=f"初始化失败: {str(e)}", data=None)
@jwc_router.post(
"/evaluation/start",
summary="开始评价任务",
response_model=TaskOperationResponse | AuthmeResponse,
)
async def start_evaluation_task(client: JWCClient = Depends(get_jwc_client)):
"""开始评价任务"""
try:
user_id = getattr(client, "user_id", "unknown")
# 检查是否已有运行中的任务
existing_manager = get_task_manager(user_id)
if existing_manager:
current_status = existing_manager.get_task_status().status
if current_status.value in [
TaskStatusEnum.RUNNING.value,
TaskStatusEnum.PAUSED.value,
]:
task_data = TaskOperationData(
task_status=TaskStatusEnum(current_status.value)
)
return TaskOperationResponse(
code=400,
message="您已有一个评价任务在运行中,请先完成或终止当前任务",
data=task_data,
)
task_manager = get_task_manager(user_id, client)
if not task_manager:
task_data = TaskOperationData(task_status=TaskStatusEnum.FAILED)
return TaskOperationResponse(
code=400, message="任务管理器不存在,请先初始化", data=task_data
)
success = await task_manager.start_evaluation_task()
stats = task_manager.get_task_status()
task_data = TaskOperationData(task_status=TaskStatusEnum(stats.status.value))
return TaskOperationResponse(
code=200 if success else 400,
message="任务已启动" if success else "任务启动失败,可能已有任务在运行",
data=task_data,
)
except Exception as e:
task_data = TaskOperationData(task_status=TaskStatusEnum.FAILED)
return TaskOperationResponse(
code=500, message=f"启动任务失败: {str(e)}", data=task_data
)
@jwc_router.post(
"/evaluation/terminate",
summary="终止评价任务",
response_model=TaskOperationResponse | AuthmeResponse,
)
async def terminate_evaluation_task(client: JWCClient = Depends(get_jwc_client)):
"""终止评价任务"""
try:
user_id = getattr(client, "user_id", "unknown")
task_manager = get_task_manager(user_id)
if not task_manager:
task_data = TaskOperationData(task_status=TaskStatusEnum.IDLE)
return TaskOperationResponse(
code=400, message="任务管理器不存在", data=task_data
)
success = await task_manager.terminate_task()
stats = task_manager.get_task_status()
# 移除任务管理器
remove_task_manager(user_id)
task_data = TaskOperationData(task_status=TaskStatusEnum(stats.status.value))
return TaskOperationResponse(
code=200 if success else 400,
message="任务已终止" if success else "终止失败",
data=task_data,
)
except Exception as e:
task_data = TaskOperationData(task_status=TaskStatusEnum.FAILED)
return TaskOperationResponse(
code=500, message=f"终止任务失败: {str(e)}", data=task_data
)
@jwc_router.post(
"/evaluation/status",
summary="获取评价任务状态",
response_model=EvaluationStatsResponse | AuthmeResponse,
)
async def get_evaluation_task_status(client: JWCClient = Depends(get_jwc_client)):
"""获取评价任务状态"""
try:
user_id = getattr(client, "user_id", "unknown")
task_manager = get_task_manager(user_id)
if not task_manager:
return EvaluationStatsResponse(code=200, message="无活跃任务", data=None)
stats = task_manager.get_task_status()
# 转换课程列表格式
course_list = []
for course in stats.course_list:
course_info = CourseInfo(
course_id=(
getattr(course.id, "coure_sequence_number", "") if course.id else ""
),
course_name=course.evaluation_content,
teacher_name=course.evaluated_people,
is_evaluated=course.is_evaluated,
evaluation_content=course.evaluation_content,
)
course_list.append(course_info)
stats_data = EvaluationStatsData(
total_courses=stats.total_courses,
pending_courses=stats.pending_courses,
success_count=stats.success_count,
fail_count=stats.fail_count,
current_index=stats.current_index,
status=TaskStatusEnum(stats.status.value),
current_countdown=stats.current_countdown,
start_time=stats.start_time,
end_time=stats.end_time,
error_message=stats.error_message,
course_list=course_list,
)
return EvaluationStatsResponse(code=200, message=stats.message, data=stats_data)
except Exception as e:
return EvaluationStatsResponse(
code=500, message=f"获取状态失败: {str(e)}", data=None
)
@jwc_router.post(
"/evaluation/current",
summary="获取当前评价课程信息",
response_model=CurrentCourseInfoResponse | AuthmeResponse,
)
async def get_current_course_info(client: JWCClient = Depends(get_jwc_client)):
"""获取当前评价课程信息"""
try:
user_id = getattr(client, "user_id", "unknown")
task_manager = get_task_manager(user_id)
if not task_manager:
return CurrentCourseInfoResponse(code=200, message="无活跃任务", data=None)
current_info = task_manager.get_current_course_info()
course_info_data = CurrentCourseInfoData(
is_evaluating=current_info.is_evaluating,
course_name=current_info.course_name,
teacher_name=current_info.teacher_name,
progress_text=current_info.progress_text,
countdown_seconds=current_info.countdown_seconds,
current_index=current_info.current_index,
total_pending=current_info.total_pending,
)
return CurrentCourseInfoResponse(
code=200, message="获取成功", data=course_info_data
)
except Exception as e:
return CurrentCourseInfoResponse(
code=500, message=f"获取信息失败: {str(e)}", data=None
)
# ==================== 学期和成绩相关API ====================
@jwc_router.post(
"/fetch_all_terms",
summary="获取所有学期信息",
response_model=AllTermsResponse | AuthmeResponse | ErrorResponse,
)
async def fetch_all_terms(client: JWCClient = Depends(get_jwc_client)):
"""获取所有可查询的学期信息"""
try:
result = await client.fetch_all_terms()
# 检查结果
if result and len(result) > 0:
return AllTermsResponse.success(data=result, message="学期信息获取成功")
else:
return AllTermsResponse.error(
message="获取学期信息失败,网络请求多次重试后仍无法连接教务系统,请稍后重试或联系管理员",
code=500,
data={},
)
except Exception as e:
return ErrorResponse(message=f"获取学期信息时发生系统错误:{str(e)}", code=500)
@jwc_router.post(
"/fetch_term_score",
summary="获取指定学期成绩",
response_model=TermScoreAPIResponse | AuthmeResponse | ErrorResponse,
)
async def fetch_term_score(
request: FetchTermScoreRequest,
client: JWCClient = Depends(get_jwc_client),
):
"""
获取指定学期的成绩信息
"""
try:
raw_result = await client.fetch_term_score(
term_id=request.term_id,
course_code=request.course_code,
course_name=request.course_name,
page_num=request.page_num,
page_size=request.page_size,
)
if not raw_result:
return TermScoreAPIResponse.error(
message="获取成绩信息失败,网络请求多次重试后仍无法连接教务系统,请稍后重试或联系管理员",
code=500,
data=None,
)
try:
# 解析原始数据为结构化数据
from provider.aufe.jwc.model import TermScoreResponse, ScoreRecord
list_data = raw_result.get("list", {})
page_context = list_data.get("pageContext", {})
records_raw = list_data.get("records", [])
# 转换记录格式
score_records = []
for record in records_raw:
if len(record) >= 13: # 确保数据完整
score_record = ScoreRecord(
sequence=record[0] if record[0] else 0,
term_id=record[1] if record[1] else "",
course_code=record[2] if record[2] else "",
course_class=record[3] if record[3] else "",
course_name_cn=record[4] if record[4] else "",
course_name_en=record[5] if record[5] else "",
credits=record[6] if record[6] else "",
hours=record[7] if record[7] else 0,
course_type=record[8] if record[8] else "",
exam_type=record[9] if record[9] else "",
score=record[10] if record[10] else "",
retake_score=(
record[11] if len(record) > 11 and record[11] else None
),
makeup_score=(
record[12] if len(record) > 12 and record[12] else None
),
)
score_records.append(score_record)
result = TermScoreResponse(
page_size=list_data.get("pageSize", 50),
page_num=list_data.get("pageNum", 1),
total_count=page_context.get("totalCount", 0),
records=score_records,
)
return TermScoreAPIResponse(
code=200,
message="success",
data=result,
)
except Exception as parse_error:
return TermScoreAPIResponse.error(
message=f"解析成绩数据失败:{str(parse_error)}", code=500, data=None
)
except Exception as e:
logger.error(f"获取学期成绩失败: {str(e)}")
return ErrorResponse(code=1, message=f"获取学期成绩失败: {str(e)}")
@jwc_router.post(
"/fetch_course_schedule",
summary="获取课表信息",
response_model=ScheduleResponse | AuthmeResponse | ErrorResponse,
)
async def fetch_course_schedule(
request: FetchScheduleRequest,
client: JWCClient = Depends(get_jwc_client)
):
"""
获取聚合的课表信息,包含:
- 课程基本信息(课程名、教师、学分等)
- 上课时间和地点信息
- 时间段详情
- 学期信息
特殊处理:
- 自动过滤无用字段
- 标记没有具体时间安排的课程
- 清理教师姓名中的特殊字符
"""
try:
logger.info(f"获取课表请求: plan_code={request.plan_code}")
# 检查环境和Cookie有效性
is_valid = await client.validate_environment_and_cookie()
if not is_valid:
return AuthmeResponse(
code=401,
message="Cookie已失效或不在VPN/校园网环境,请重新登录",
)
# 获取处理后的课表数据
schedule_data = await client.get_processed_schedule(request.plan_code)
if not schedule_data:
return ErrorResponse(
code=1,
message="获取课表信息失败,请稍后重试"
)
return ScheduleResponse(
code=0,
message="success",
data=schedule_data,
)
except Exception as e:
logger.error(f"获取课表信息失败: {str(e)}")
return ErrorResponse(code=1, message=f"获取课表信息失败: {str(e)}")

670
router/jwc/evaluate.py Normal file
View File

@@ -0,0 +1,670 @@
from provider.aufe.jwc import JWCClient
from provider.aufe.jwc.model import Course, EvaluationRequestParam
import asyncio
import random
from datetime import datetime
from typing import Dict, List, Optional, Callable
from dataclasses import dataclass, field
from enum import Enum
from loguru import logger
class TaskStatus(Enum):
"""任务状态枚举"""
IDLE = "idle" # 空闲
INITIALIZING = "initializing" # 初始化中
RUNNING = "running" # 运行中
PAUSED = "paused" # 暂停
COMPLETED = "completed" # 完成
FAILED = "failed" # 失败
TERMINATED = "terminated" # 已终止
@dataclass
class EvaluationStats:
"""评价统计信息"""
total_courses: int = 0
pending_courses: int = 0
success_count: int = 0
fail_count: int = 0
current_index: int = 0
status: TaskStatus = TaskStatus.IDLE
message: str = ""
course_list: List[Course] = field(default_factory=list)
current_countdown: int = 0
current_course: Optional[Course] = None
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
error_message: str = ""
@dataclass
class CurrentCourseInfo:
"""当前评价课程信息"""
is_evaluating: bool = False
course_name: str = ""
teacher_name: str = ""
progress_text: str = ""
countdown_seconds: int = 0
current_index: int = -1
total_pending: int = 0
class Constants:
"""常量定义"""
# 等待评价的冷却时间(秒)
COUNTDOWN_SECONDS = 140 # 2分20秒
# 随机评价文案 - 总体评价文案
ZGPGS = [
"老师授课生动形象,课堂氛围活跃。",
"教学方法新颖,能够激发学习兴趣。",
"讲解耐心细致,知识点清晰易懂。",
"对待学生公平公正,很有亲和力。",
"课堂管理有序,效率高。",
"能理论联系实际,深入浅出。",
"作业布置合理,有助于巩固知识。",
"教学经验丰富,讲解深入浅出。",
"关注学生反馈,及时调整教学。",
"教学资源丰富,便于学习。",
"课堂互动性强,能充分调动积极性。",
"教学重点突出,难点突破到位。",
"性格开朗,课堂充满活力。",
"批改作业认真,评语有指导性。",
"教学目标明确,条理清晰。",
]
# 额外描述性文案
NICE_0000000200 = [
"常把晦涩理论生活化,知识瞬间亲近起来。",
"总用类比解难点,复杂概念秒懂。",
"引入行业前沿案例,打开视野新窗口。",
"设问巧妙引深思,激发自主探寻答案。",
"常分享学科冷知识,拓宽知识边界。",
"用跨学科视角解题,思维更灵动。",
"鼓励尝试多元解法,创新思维被激活。",
"常分享科研趣事,点燃学术热情。",
"用思维导图梳理知识,结构一目了然。",
"常把学习方法倾囊相授,效率直线提升。",
"用历史事件类比,知识记忆更深刻。",
"常鼓励跨学科学习,综合素养渐涨。",
"分享行业大咖故事,奋斗动力满满。",
"总能挖掘知识背后的趣味,学习味十足。",
"常组织知识竞赛,学习热情被点燃。",
]
# 建议文案
NICE_0000000201 = [
"",
"没有",
"没有什么建议,老师很好",
"继续保持这么好的教学风格",
"希望老师继续分享更多精彩案例",
"感谢老师的悉心指导",
]
class EvaluationTaskManager:
"""评价任务管理器 - 基于学号管理"""
def __init__(self, jwc_client: JWCClient, user_id: str):
"""
初始化评价任务管理器
Args:
jwc_client: JWC客户端实例
user_id: 用户学号
"""
self.jwc_client = jwc_client
self.user_id = user_id
self.stats = EvaluationStats()
self._task: Optional[asyncio.Task] = None
self._stop_event = asyncio.Event()
self._progress_callbacks: List[Callable[[EvaluationStats], None]] = []
logger.info(f"初始化评价任务管理器用户ID: {user_id}")
def add_progress_callback(self, callback: Callable[[EvaluationStats], None]):
"""添加进度回调函数"""
self._progress_callbacks.append(callback)
def _notify_progress(self):
"""通知所有进度回调"""
for callback in self._progress_callbacks:
try:
callback(self.stats)
except Exception as e:
logger.error(f"进度回调执行失败: {str(e)}")
async def initialize(self) -> bool:
"""
初始化评价环境
Returns:
bool: 初始化是否成功
"""
try:
self.stats.status = TaskStatus.INITIALIZING
self.stats.message = "正在检查网络..."
self._notify_progress()
# 检查网络连接
if not await self.jwc_client.check_network_connection():
self.stats.status = TaskStatus.FAILED
self.stats.message = "网络连接失败请确保连接到校园网或VPN"
self.stats.error_message = "网络连接失败"
self._notify_progress()
return False
# 验证环境和Cookie
self.stats.message = "正在验证登录状态..."
self._notify_progress()
if not await self.jwc_client.validate_environment_and_cookie():
self.stats.status = TaskStatus.FAILED
self.stats.message = "登录状态失效,请重新登录"
self.stats.error_message = "Cookie验证失败"
self._notify_progress()
return False
# 获取Token
self.stats.message = "正在获取Token..."
self._notify_progress()
token = await self.jwc_client.get_token()
if not token:
self.stats.status = TaskStatus.FAILED
self.stats.message = "获取Token失败可能是评教系统未开放"
self.stats.error_message = "Token获取失败"
self._notify_progress()
return False
# 获取课程列表
self.stats.message = "正在获取课程列表..."
self._notify_progress()
courses = await self.jwc_client.fetch_evaluation_course_list()
if not courses:
self.stats.status = TaskStatus.FAILED
self.stats.message = "未获取到课程列表,请稍后再试"
self.stats.error_message = "课程列表获取失败"
self._notify_progress()
return False
# 更新统计信息
pending_courses = [
course
for course in courses
if getattr(course, "is_evaluated", "") != ""
]
self.stats.course_list = courses
self.stats.total_courses = len(courses)
self.stats.pending_courses = len(pending_courses)
self.stats.status = TaskStatus.IDLE
self.stats.message = (
f"初始化完成,找到 {self.stats.pending_courses} 门待评价课程"
)
self.stats.current_course = None
logger.info(
f"用户 {self.user_id} 初始化完成,待评价课程: {self.stats.pending_courses}"
)
self._notify_progress()
return True
except Exception as e:
self.stats.status = TaskStatus.FAILED
self.stats.message = f"初始化异常: {str(e)}"
self.stats.error_message = str(e)
logger.error(f"用户 {self.user_id} 初始化失败: {str(e)}")
self._notify_progress()
return False
async def evaluate_course(self, course: Course, token: str) -> bool:
"""
评价单门课程
Args:
course: 课程信息
token: CSRF Token
Returns:
bool: 评价是否成功
"""
try:
# 设置当前课程
self.stats.current_course = course
# 如果课程已评价,则跳过
if getattr(course, "is_evaluated", "") == "":
logger.info(f"课程已评价,跳过: {course.evaluation_content}")
return True
# 第一步:访问评价页面
if not await self.jwc_client.access_evaluation_page(token, course):
return False
course_name = course.evaluation_content
logger.info(f"正在准备评价: {course_name}")
self.stats.message = "已访问评价页面,等待服务器倒计时完成后提交评价..."
self._notify_progress()
# 等待服务器倒计时
server_wait_time = Constants.COUNTDOWN_SECONDS
# 显示倒计时
for second in range(server_wait_time, 0, -1):
# 检查是否被终止
if self._stop_event.is_set():
self.stats.status = TaskStatus.TERMINATED
self.stats.message = "任务已被终止"
self._notify_progress()
return False
self.stats.current_countdown = second
self.stats.message = f"服务器倒计时: {second} 秒,然后提交评价..."
self._notify_progress()
await asyncio.sleep(1)
self.stats.current_countdown = 0
self.stats.message = "倒计时结束,正在提交评价..."
self._notify_progress()
# 生成评价数据
evaluation_ratings = {}
for i in range(180, 202):
key = f"0000000{i}"
if i == 200:
evaluation_ratings[key] = random.choice(Constants.NICE_0000000200)
elif i == 201:
evaluation_ratings[key] = random.choice(Constants.NICE_0000000201)
else:
evaluation_ratings[key] = f"5_{random.choice(['0.8', '1'])}"
# 创建评价请求参数
evaluation_param = EvaluationRequestParam(
token_value=token,
questionnaire_code=(
course.questionnaire.questionnaire_number
if course.questionnaire
else ""
),
evaluation_content=(
course.id.evaluation_content_number if course.id else ""
),
evaluated_people_number=course.id.evaluated_people if course.id else "",
zgpj=random.choice(Constants.ZGPGS),
rating_items=evaluation_ratings,
)
# 提交评价
response = await self.jwc_client.submit_evaluation(evaluation_param)
success = response.result == "success"
if success:
logger.info(f"课程评价成功: {course_name}")
else:
logger.error(f"课程评价失败: {course_name}, 错误: {response.msg}")
# 清除当前课程信息
self.stats.current_course = None
self.stats.current_countdown = 0
return success
except Exception as e:
logger.error(f"评价课程异常: {str(e)}")
return False
async def start_evaluation_task(self) -> bool:
"""
开始评价任务
确保一个用户只能有一个运行中的任务
Returns:
bool: 任务是否成功启动
"""
# 检查当前状态
if self.stats.status == TaskStatus.RUNNING:
logger.warning(f"用户 {self.user_id} 的评价任务已在运行中")
return False
if self.stats.status == TaskStatus.INITIALIZING:
logger.warning(f"用户 {self.user_id} 的评价任务正在初始化中")
return False
# 检查是否有未完成的异步任务
if self._task and not self._task.done():
logger.warning(f"用户 {self.user_id} 已有任务在执行")
return False
# 确保任务已经初始化
if self.stats.status == TaskStatus.IDLE and len(self.stats.course_list) == 0:
logger.warning(f"用户 {self.user_id} 任务未初始化请先调用initialize")
return False
# 重置停止事件
self._stop_event.clear()
# 创建新任务
self._task = asyncio.create_task(self._evaluate_all_courses())
logger.info(f"用户 {self.user_id} 开始评价任务")
return True
async def _evaluate_all_courses(self):
"""批量评价所有课程(内部方法)"""
try:
# 获取Token
token = await self.jwc_client.get_token()
if not token:
self.stats.status = TaskStatus.FAILED
self.stats.message = "获取Token失败"
self._notify_progress()
return
# 获取待评价课程
pending_courses = [
course
for course in self.stats.course_list
if getattr(course, "is_evaluated", "") != ""
]
if not pending_courses:
self.stats.status = TaskStatus.COMPLETED
self.stats.message = "所有课程已评价完成!"
self._notify_progress()
return
# 开始评价流程
self.stats.status = TaskStatus.RUNNING
self.stats.success_count = 0
self.stats.fail_count = 0
self.stats.current_course = None
self.stats.start_time = datetime.now()
index = 0
while index < len(pending_courses):
# 检查是否被终止
if self._stop_event.is_set():
self.stats.status = TaskStatus.TERMINATED
self.stats.message = "任务已被终止"
self.stats.end_time = datetime.now()
self._notify_progress()
return
course = pending_courses[index]
self.stats.current_index = index
self.stats.current_course = course
course_name = getattr(
course.questionnaire,
"questionnaire_name",
course.evaluation_content,
)
self.stats.message = f"正在处理第 {index + 1}/{len(pending_courses)} 门课程: {course_name}"
self._notify_progress()
# 评价当前课程
success = await self.evaluate_course(course, token)
if success:
self.stats.success_count += 1
self.stats.message = f"课程评价成功: {course_name}"
else:
self.stats.fail_count += 1
self.stats.message = f"课程评价失败: {course_name}"
self._notify_progress()
# 评价完一门课程后,重新获取课程列表
self.stats.message = "正在更新课程列表..."
self._notify_progress()
# 重新获取课程列表
updated_courses = await self.jwc_client.fetch_evaluation_course_list()
if updated_courses:
self.stats.course_list = updated_courses
pending_courses = [
course
for course in updated_courses
if getattr(course, "is_evaluated", "") != ""
]
self.stats.total_courses = len(updated_courses)
self.stats.pending_courses = len(pending_courses)
self.stats.message = (
f"课程列表已更新,剩余待评价课程: {self.stats.pending_courses}"
)
self._notify_progress()
# 给服务器一些处理时间
if pending_courses and index < len(pending_courses) - 1:
self.stats.message = "准备处理下一门课程..."
self._notify_progress()
await asyncio.sleep(3)
index += 1
# 评价完成
self.stats.status = TaskStatus.COMPLETED
self.stats.current_course = None
self.stats.end_time = datetime.now()
self.stats.message = f"评价完成!成功: {self.stats.success_count},失败: {self.stats.fail_count}"
logger.info(
f"用户 {self.user_id} 评价任务完成,成功: {self.stats.success_count},失败: {self.stats.fail_count}"
)
self._notify_progress()
except Exception as e:
self.stats.status = TaskStatus.FAILED
self.stats.error_message = str(e)
self.stats.message = f"评价任务异常: {str(e)}"
self.stats.end_time = datetime.now()
logger.error(f"用户 {self.user_id} 评价任务异常: {str(e)}")
self._notify_progress()
async def pause_task(self) -> bool:
"""
暂停任务
Returns:
bool: 是否成功暂停
"""
if self.stats.status != TaskStatus.RUNNING:
return False
self.stats.status = TaskStatus.PAUSED
self.stats.message = "任务已暂停"
logger.info(f"用户 {self.user_id} 任务已暂停")
self._notify_progress()
return True
async def resume_task(self) -> bool:
"""
恢复任务
Returns:
bool: 是否成功恢复
"""
if self.stats.status != TaskStatus.PAUSED:
return False
self.stats.status = TaskStatus.RUNNING
self.stats.message = "任务已恢复"
logger.info(f"用户 {self.user_id} 任务已恢复")
self._notify_progress()
return True
async def terminate_task(self) -> bool:
"""
终止任务
Returns:
bool: 是否成功终止
"""
if self.stats.status not in [TaskStatus.RUNNING, TaskStatus.PAUSED]:
return False
# 设置停止事件
self._stop_event.set()
# 如果有运行中的任务,等待其完成
if self._task and not self._task.done():
try:
await asyncio.wait_for(self._task, timeout=5.0)
except asyncio.TimeoutError:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self.stats.status = TaskStatus.TERMINATED
self.stats.message = "任务已终止"
self.stats.end_time = datetime.now()
logger.info(f"用户 {self.user_id} 任务已终止")
self._notify_progress()
return True
def get_current_course_info(self) -> CurrentCourseInfo:
"""
获取当前评价课程信息
Returns:
CurrentCourseInfo: 当前课程信息
"""
# 如果没有运行评价任务
if self.stats.status != TaskStatus.RUNNING:
return CurrentCourseInfo(
is_evaluating=False, progress_text="当前无评价任务"
)
# 正在评价但还没有确定是哪门课程
if (
self.stats.current_index < 0
or self.stats.current_index >= len(self.stats.course_list)
or self.stats.current_course is None
):
return CurrentCourseInfo(
is_evaluating=True,
progress_text="准备中...",
total_pending=self.stats.pending_courses,
)
# 正在评价特定课程
course = self.stats.current_course
pending_courses = [
c
for c in self.stats.course_list
if getattr(c, "is_evaluated", "") != ""
]
index = self.stats.current_index + 1
total = len(pending_courses)
countdown_text = (
f" (倒计时: {self.stats.current_countdown}秒)"
if self.stats.current_countdown > 0
else ""
)
course_name = course.evaluation_content[:20]
if len(course.evaluation_content) > 20:
course_name += "..."
return CurrentCourseInfo(
is_evaluating=True,
course_name=course_name,
teacher_name=course.evaluated_people,
progress_text=f"正在评价({index}/{total}): {course_name} - {course.evaluated_people}{countdown_text}",
countdown_seconds=self.stats.current_countdown,
current_index=self.stats.current_index,
total_pending=total,
)
def get_task_status(self) -> EvaluationStats:
"""
获取任务状态
Returns:
EvaluationStats: 任务统计信息
"""
return self.stats
def get_user_id(self) -> str:
"""获取用户ID"""
return self.user_id
# 全局任务管理器字典,以学号为键
_task_managers: Dict[str, EvaluationTaskManager] = {}
def get_task_manager(
user_id: str, jwc_client: Optional[JWCClient] = None
) -> Optional[EvaluationTaskManager]:
"""
获取或创建任务管理器
一个用户只能有一个活跃的任务管理器
Args:
user_id: 用户学号
jwc_client: JWC客户端创建新管理器时需要
Returns:
Optional[EvaluationTaskManager]: 任务管理器实例
"""
if user_id in _task_managers:
existing_manager = _task_managers[user_id]
# 检查现有任务的状态
current_status = existing_manager.get_task_status().status
# 如果任务已完成、失败或终止,自动清理
if current_status in [
TaskStatus.COMPLETED,
TaskStatus.FAILED,
TaskStatus.TERMINATED,
]:
logger.info(f"自动清理用户 {user_id} 的已完成任务")
del _task_managers[user_id]
else:
# 返回现有的管理器
return existing_manager
# 创建新的管理器
if jwc_client is None:
return None
manager = EvaluationTaskManager(jwc_client, user_id)
_task_managers[user_id] = manager
logger.info(f"为用户 {user_id} 创建新的任务管理器")
return manager
def remove_task_manager(user_id: str) -> bool:
"""
移除任务管理器
Args:
user_id: 用户学号
Returns:
bool: 是否成功移除
"""
if user_id in _task_managers:
del _task_managers[user_id]
return True
return False
def get_all_task_managers() -> Dict[str, EvaluationTaskManager]:
"""获取所有任务管理器"""
return _task_managers.copy()

View File

@@ -0,0 +1,95 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
from enum import Enum
from router.common_model import BaseResponse
class TaskStatusEnum(str, Enum):
"""任务状态枚举"""
IDLE = "idle"
INITIALIZING = "initializing"
RUNNING = "running"
PAUSED = "paused"
COMPLETED = "completed"
FAILED = "failed"
TERMINATED = "terminated"
class CourseInfo(BaseModel):
"""课程信息响应模型"""
course_id: str = Field("", description="课程ID")
course_name: str = Field("", description="课程名称")
teacher_name: str = Field("", description="教师姓名")
is_evaluated: str = Field("", description="是否已评价")
evaluation_content: str = Field("", description="评价内容")
# 统一响应数据模型
class EvaluationStatsData(BaseModel):
"""评价统计信息数据模型"""
total_courses: int = Field(0, description="总课程数")
pending_courses: int = Field(0, description="待评价课程数")
success_count: int = Field(0, description="成功评价数")
fail_count: int = Field(0, description="失败评价数")
current_index: int = Field(0, description="当前评价索引")
status: TaskStatusEnum = Field(TaskStatusEnum.IDLE, description="任务状态")
current_countdown: int = Field(0, description="当前倒计时")
start_time: Optional[datetime] = Field(None, description="开始时间")
end_time: Optional[datetime] = Field(None, description="结束时间")
error_message: str = Field("", description="错误消息")
course_list: List[CourseInfo] = Field(default_factory=list, description="课程列表")
class CurrentCourseInfoData(BaseModel):
"""当前评价课程信息数据模型"""
is_evaluating: bool = Field(False, description="是否正在评价")
course_name: str = Field("", description="课程名称")
teacher_name: str = Field("", description="教师姓名")
progress_text: str = Field("", description="进度文本")
countdown_seconds: int = Field(0, description="倒计时秒数")
current_index: int = Field(-1, description="当前索引")
total_pending: int = Field(0, description="总待评价数")
class TaskOperationData(BaseModel):
"""任务操作数据模型"""
task_status: TaskStatusEnum = Field(TaskStatusEnum.IDLE, description="任务状态")
class InitializeData(BaseModel):
"""初始化数据模型"""
total_courses: int = Field(0, description="总课程数")
pending_courses: int = Field(0, description="待评价课程数")
course_list: List[CourseInfo] = Field(default_factory=list, description="课程列表")
# 统一响应模型
class EvaluationStatsResponse(BaseResponse[EvaluationStatsData]):
"""评价统计信息响应模型"""
pass
class CurrentCourseInfoResponse(BaseResponse[CurrentCourseInfoData]):
"""当前评价课程信息响应模型"""
pass
class TaskOperationResponse(BaseResponse[TaskOperationData]):
"""任务操作响应模型"""
pass
class InitializeResponse(BaseResponse[InitializeData]):
"""初始化响应模型"""
pass

122
router/jwc/model.py Normal file
View File

@@ -0,0 +1,122 @@
from router.common_model import BaseResponse
from provider.aufe.jwc.model import (
AcademicInfo,
TrainingPlanInfo,
Course,
ExamInfoResponse,
TermScoreResponse,
)
from typing import List, Dict, Optional
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")
end_time: str = Field(..., description="结束时间格式HHMM")
time_length: str = Field(..., description="时长(分钟)")
djjc: int = Field(..., description="大节节次")
class CourseTimeLocation(BaseModel):
"""课程时间地点模型"""
class_day: int = Field(..., description="上课星期几1-7")
class_sessions: int = Field(..., description="上课节次")
continuing_session: int = Field(..., description="持续节次数")
class_week: str = Field(..., description="上课周次24位二进制字符串")
week_description: str = Field(..., description="上课周次描述")
campus_name: str = Field(..., description="校区名称")
teaching_building_name: str = Field(..., description="教学楼名称")
classroom_name: str = Field(..., description="教室名称")
class ScheduleCourse(BaseModel):
"""课表课程模型"""
course_name: str = Field(..., description="课程名称")
course_code: str = Field(..., description="课程代码")
course_sequence: str = Field(..., description="课程序号")
teacher_name: str = Field(..., description="授课教师")
course_properties: str = Field(..., description="课程性质")
exam_type: str = Field(..., description="考试类型")
unit: float = Field(..., description="学分")
time_locations: List[CourseTimeLocation] = Field(..., description="时间地点列表")
is_no_schedule: bool = Field(False, description="是否无具体时间安排")
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")

109
router/login/__init__.py Normal file
View File

@@ -0,0 +1,109 @@
from fastapi import Depends
from fastapi.routing import APIRouter
from database.user import User
from database.creator import get_db_session
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from router.login.model import (
LoginRequest,
LoginResponse,
AuthmeResponse,
AuthmeStatusData,
)
from router.invite.model import AuthMeData
from provider.aufe.client import AUFEConnection
from provider.loveac.authme import manage_user_tokens, generate_device_id, fetch_user_by_token, AuthmeRequest
import secrets
login_router = APIRouter(prefix="/api/v1/user")
@login_router.post("/login", summary="用户登录")
async def login_user(
data: LoginRequest, asyncsession: AsyncSession = Depends(get_db_session)
) -> LoginResponse:
"""
用户登录
:param data: LoginRequest
:return: LoginResponse
"""
async with asyncsession as session:
userid = data.userid
password = data.password
easyconnect_password = data.easyconnect_password
# 检查用户是否存在
existing_user = await session.execute(select(User).where(User.userid == userid))
user = existing_user.scalars().first()
if not user:
return LoginResponse(
code=400,
message="用户不存在",
data=None,
)
# 检查连接
vpn = AUFEConnection.create_or_get_connection("vpn.aufe.edu.cn", userid)
# 检查连接是否已经存在,避免重复登录
if not vpn.login_status():
if not await vpn.login(userid, easyconnect_password):
return LoginResponse(
code=400,
message="VPN登录失败请检查用户名和密码",
data=None,
)
if not vpn.uaap_login_status():
if not await vpn.uaap_login(userid, password):
return LoginResponse(
code=400,
message="大学登录失败,请检查用户名和密码",
data=None,
)
# 生成新的token和设备ID
authme_token = secrets.token_urlsafe(128)
device_id = generate_device_id()
# 使用新的token管理系统
await manage_user_tokens(userid, authme_token, device_id, session)
return LoginResponse(
code=200,
message="登录成功",
data=AuthMeData(authme_token=authme_token),
)
@login_router.post("/authme", summary="验证登录状态")
async def check_auth_status(
data: AuthmeRequest, asyncsession: AsyncSession = Depends(get_db_session)
) -> AuthmeResponse:
"""
验证token是否有效返回登录状态
:param data: AuthmeRequest
:return: AuthmeResponse
"""
try:
# 使用已有的fetch_user_by_token函数验证token
user = await fetch_user_by_token(data, asyncsession)
return AuthmeResponse(
code=200,
message="验证成功",
data=AuthmeStatusData(
is_logged_in=True,
userid=user.userid
),
)
except Exception as e:
# token无效或其他错误
return AuthmeResponse(
code=401,
message="token无效或已过期",
data=AuthmeStatusData(
is_logged_in=False,
userid=None
),
)

31
router/login/model.py Normal file
View File

@@ -0,0 +1,31 @@
from pydantic import BaseModel, Field
from router.common_model import BaseResponse
from router.invite.model import AuthMeData
from typing import Optional
class LoginRequest(BaseModel):
userid: str = Field(..., description="学号")
password: str = Field(..., description="密码")
easyconnect_password: str = Field(..., description="VPN密码")
# 统一响应模型
class LoginResponse(BaseResponse[AuthMeData]):
"""登录响应"""
pass
# Authme相关模型
class AuthmeStatusData(BaseModel):
"""认证状态数据"""
is_logged_in: bool = Field(..., description="是否处于登录状态")
userid: Optional[str] = Field(None, description="用户ID")
class AuthmeResponse(BaseResponse[AuthmeStatusData]):
"""AuthMe验证响应"""
pass

242
router/user/__init__.py Normal file
View File

@@ -0,0 +1,242 @@
import base64
from fastapi import Depends
from fastapi.routing import APIRouter
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from database.creator import get_db_session
from database.user import UserProfile, User
from provider.loveac.authme import fetch_user_by_token, AuthmeRequest
from utils.file_manager import file_manager
from .model import (
UserProfileResponse,
GetUserProfileRequest,
UpdateUserProfileRequest,
UserProfileData,
UserSettings,
)
user_router = APIRouter(prefix="/api/v1/user")
@user_router.post("/profile/get", summary="获取用户资料")
async def get_user_profile(
data: GetUserProfileRequest,
asyncsession: AsyncSession = Depends(get_db_session),
):
"""
获取用户资料
:param data: GetUserProfileRequest
:return: UserProfileResponse
"""
try:
# 使用token验证获取用户
authme_request = AuthmeRequest(token=data.token)
user = await fetch_user_by_token(authme_request, asyncsession)
async with asyncsession as session:
result = await session.execute(
select(UserProfile).where(UserProfile.userid == user.userid)
)
profile = result.scalars().first()
if not profile:
# 如果用户资料不存在,创建默认资料
profile = UserProfile(
userid=user.userid,
avatar_filename=None,
background_filename=None,
nickname=None,
settings_filename=None
)
session.add(profile)
await session.commit()
# 获取头像数据
avatar_data = None
if profile.avatar_filename:
avatar_bytes = await file_manager.get_avatar(profile.avatar_filename)
if avatar_bytes:
# 转换为base64
avatar_data = base64.b64encode(avatar_bytes).decode('utf-8')
# 根据文件扩展名添加data URI前缀
if profile.avatar_filename.endswith('.png'):
avatar_data = f"data:image/png;base64,{avatar_data}"
elif profile.avatar_filename.endswith(('.jpg', '.jpeg')):
avatar_data = f"data:image/jpeg;base64,{avatar_data}"
elif profile.avatar_filename.endswith('.gif'):
avatar_data = f"data:image/gif;base64,{avatar_data}"
# 获取背景数据
background_data = None
if profile.background_filename:
background_bytes = await file_manager.get_background(profile.background_filename)
if background_bytes:
# 转换为base64
background_data = base64.b64encode(background_bytes).decode('utf-8')
# 根据文件扩展名添加data URI前缀
if profile.background_filename.endswith('.png'):
background_data = f"data:image/png;base64,{background_data}"
elif profile.background_filename.endswith(('.jpg', '.jpeg')):
background_data = f"data:image/jpeg;base64,{background_data}"
elif profile.background_filename.endswith('.gif'):
background_data = f"data:image/gif;base64,{background_data}"
elif profile.background_filename.endswith('.webp'):
background_data = f"data:image/webp;base64,{background_data}"
# 获取设置数据
settings_data = None
if profile.settings_filename:
settings_dict = await file_manager.get_settings(profile.settings_filename)
if settings_dict:
settings_data = UserSettings(**settings_dict)
profile_data = UserProfileData(
userid=profile.userid,
avatar=avatar_data,
background=background_data,
nickname=profile.nickname,
settings=settings_data,
)
return UserProfileResponse(
code=200,
message="获取用户资料成功",
data=profile_data
)
except Exception as e:
return UserProfileResponse(
code=500,
message=f"获取用户资料失败: {str(e)}",
data=None
)
@user_router.post("/profile/update", summary="更新用户资料")
async def update_user_profile(
data: UpdateUserProfileRequest,
asyncsession: AsyncSession = Depends(get_db_session),
):
"""
更新用户资料
:param data: UpdateUserProfileRequest
:return: UserProfileResponse
"""
try:
# 使用token验证获取用户
authme_request = AuthmeRequest(token=data.token)
user = await fetch_user_by_token(authme_request, asyncsession)
async with asyncsession as session:
result = await session.execute(
select(UserProfile).where(UserProfile.userid == user.userid)
)
profile = result.scalars().first()
if not profile:
# 如果用户资料不存在,创建新的
profile = UserProfile(
userid=user.userid,
avatar_filename=None,
background_filename=None,
nickname=data.nickname,
settings_filename=None
)
session.add(profile)
else:
# 更新昵称
if data.nickname is not None:
profile.nickname = data.nickname
# 处理头像更新
if data.avatar is not None:
if data.avatar: # 如果头像不为空
new_avatar_filename = await file_manager.save_avatar(user.userid, data.avatar)
profile.avatar_filename = new_avatar_filename
else: # 如果头像为空,表示删除头像
if profile.avatar_filename:
await file_manager.delete_avatar(profile.avatar_filename)
profile.avatar_filename = None
# 处理背景更新
if data.background is not None:
if data.background: # 如果背景不为空
new_background_filename = await file_manager.save_background(user.userid, data.background)
profile.background_filename = new_background_filename
else: # 如果背景为空,表示删除背景
if profile.background_filename:
await file_manager.delete_background(profile.background_filename)
profile.background_filename = None
# 处理设置更新
if data.settings is not None:
if data.settings: # 如果设置不为空
# data.settings在model验证时已经被转换为UserSettings对象
if isinstance(data.settings, UserSettings):
settings_dict = data.settings.model_dump()
new_settings_filename = await file_manager.save_settings(user.userid, settings_dict)
profile.settings_filename = new_settings_filename
else:
# 如果不是UserSettings对象说明验证有问题
raise ValueError(f"Settings对象类型错误: {type(data.settings)}")
else: # 如果设置为空,表示删除设置
if profile.settings_filename:
await file_manager.delete_settings(profile.settings_filename)
profile.settings_filename = None
await session.commit()
await session.refresh(profile)
# 获取更新后的数据
avatar_data = None
if profile.avatar_filename:
avatar_bytes = await file_manager.get_avatar(profile.avatar_filename)
if avatar_bytes:
avatar_data = base64.b64encode(avatar_bytes).decode('utf-8')
if profile.avatar_filename.endswith('.png'):
avatar_data = f"data:image/png;base64,{avatar_data}"
elif profile.avatar_filename.endswith(('.jpg', '.jpeg')):
avatar_data = f"data:image/jpeg;base64,{avatar_data}"
elif profile.avatar_filename.endswith('.gif'):
avatar_data = f"data:image/gif;base64,{avatar_data}"
background_data = None
if profile.background_filename:
background_bytes = await file_manager.get_background(profile.background_filename)
if background_bytes:
background_data = base64.b64encode(background_bytes).decode('utf-8')
if profile.background_filename.endswith('.png'):
background_data = f"data:image/png;base64,{background_data}"
elif profile.background_filename.endswith(('.jpg', '.jpeg')):
background_data = f"data:image/jpeg;base64,{background_data}"
elif profile.background_filename.endswith('.gif'):
background_data = f"data:image/gif;base64,{background_data}"
elif profile.background_filename.endswith('.webp'):
background_data = f"data:image/webp;base64,{background_data}"
settings_data = None
if profile.settings_filename:
settings_dict = await file_manager.get_settings(profile.settings_filename)
if settings_dict:
settings_data = UserSettings(**settings_dict)
profile_data = UserProfileData(
userid=profile.userid,
avatar=avatar_data,
background=background_data,
nickname=profile.nickname,
settings=settings_data,
)
return UserProfileResponse(
code=200,
message="更新用户资料成功",
data=profile_data
)
except Exception as e:
return UserProfileResponse(
code=500,
message=f"更新用户资料失败: {str(e)}",
data=None
)

79
router/user/model.py Normal file
View File

@@ -0,0 +1,79 @@
from pydantic import BaseModel, Field, field_validator
from router.common_model import BaseResponse
from typing import Optional, Dict, Any, Union
import json
class UserSettings(BaseModel):
"""用户设置模型"""
theme: str = Field(..., description="主题模式")
lightModeOpacity: float = Field(..., description="浅色模式透明度", ge=0.0, le=1.0)
lightModeBrightness: float = Field(..., description="浅色模式亮度", ge=0.0, le=1.0)
darkModeOpacity: float = Field(..., description="深色模式透明度", ge=0.0, le=1.0)
darkModeBrightness: float = Field(..., description="深色模式亮度", ge=0.0, le=1.0)
backgroundBlur: float = Field(..., description="背景模糊强度", ge=0.0, le=1.0)
@field_validator('theme')
def validate_theme(cls, v):
"""验证主题值"""
valid_themes = ['light', 'dark', 'system', 'ThemeMode.light', 'ThemeMode.dark', 'ThemeMode.system']
if v not in valid_themes:
raise ValueError(f"无效的主题值: {v},有效值: {valid_themes}")
return v
class UserProfileData(BaseModel):
"""用户资料数据模型"""
userid: str = Field(..., description="用户ID")
avatar: Optional[str] = Field(None, description="用户头像base64数据")
background: Optional[str] = Field(None, description="用户背景base64数据")
nickname: Optional[str] = Field(None, description="用户昵称")
settings: Optional[UserSettings] = Field(None, description="用户设置对象")
class GetUserProfileRequest(BaseModel):
"""获取用户资料请求模型"""
token: str = Field(..., description="用户认证token")
class UpdateUserProfileRequest(BaseModel):
"""更新用户资料请求模型"""
token: str = Field(..., description="用户认证token")
avatar: Optional[str] = Field(None, description="用户头像base64编码数据")
background: Optional[str] = Field(None, description="用户背景base64编码数据")
nickname: Optional[str] = Field(None, description="用户昵称")
settings: Optional[Union[UserSettings, str]] = Field(None, description="用户设置对象或JSON字符串")
@field_validator('settings')
def parse_settings(cls, v):
"""解析settings字段支持字符串和对象两种格式"""
if v is None:
return v
# 如果已经是UserSettings对象直接返回
if isinstance(v, UserSettings):
return v
# 如果是字符串尝试解析为JSON然后创建UserSettings对象
if isinstance(v, str):
try:
settings_dict = json.loads(v)
return UserSettings(**settings_dict)
except json.JSONDecodeError as e:
raise ValueError(f"settings字段JSON格式错误: {str(e)}")
except Exception as e:
raise ValueError(f"settings字段验证失败: {str(e)}")
# 如果是字典直接创建UserSettings对象
if isinstance(v, dict):
try:
return UserSettings(**v)
except Exception as e:
raise ValueError(f"settings字段验证失败: {str(e)}")
raise ValueError("settings字段必须是JSON字符串、字典或UserSettings对象")
class UserProfileResponse(BaseResponse[UserProfileData]):
"""用户资料响应模型"""
pass

6
utils/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
try:
from .s3_client import s3_client
__all__ = ["s3_client"]
except ImportError:
# 如果S3客户端依赖不可用则不导出
__all__ = []

322
utils/file_manager.py Normal file
View File

@@ -0,0 +1,322 @@
import os
import uuid
import json
import base64
import aiofiles
import glob
from typing import Optional, Dict, Any
from pathlib import Path
class FileManager:
def __init__(self, base_path: str = "data"):
self.base_path = Path(base_path)
self.avatar_path = self.base_path / "avatars"
self.background_path = self.base_path / "backgrounds"
self.settings_path = self.base_path / "settings"
# 确保目录存在
self.avatar_path.mkdir(parents=True, exist_ok=True)
self.background_path.mkdir(parents=True, exist_ok=True)
self.settings_path.mkdir(parents=True, exist_ok=True)
def generate_file_id(self) -> str:
"""生成文件ID"""
return str(uuid.uuid4())
async def cleanup_user_files(self, userid: str, file_type: str) -> None:
"""
清理用户的所有旧文件
:param userid: 用户ID
:param file_type: 文件类型 ('avatar', 'background', 'settings')
"""
if file_type == 'avatar':
pattern = self.avatar_path / f"{userid}_*"
elif file_type == 'background':
pattern = self.background_path / f"{userid}_*"
elif file_type == 'settings':
pattern = self.settings_path / f"{userid}_*"
else:
return
# 删除所有匹配的文件
for file_path in glob.glob(str(pattern)):
try:
Path(file_path).unlink()
except Exception:
pass # 忽略删除失败
async def save_avatar(self, userid: str, avatar_base64: str) -> str:
"""
保存用户头像,删除旧头像
:param userid: 用户ID
:param avatar_base64: base64编码的头像数据
:return: 文件名
"""
if not avatar_base64:
return ""
try:
# 先清理旧的头像文件
await self.cleanup_user_files(userid, 'avatar')
# 解析base64数据
if avatar_base64.startswith('data:'):
# 处理data URI格式
header, data = avatar_base64.split(',', 1)
# 提取文件格式
if 'image/png' in header:
ext = 'png'
elif 'image/jpeg' in header or 'image/jpg' in header:
ext = 'jpg'
elif 'image/gif' in header:
ext = 'gif'
else:
ext = 'png' # 默认格式
else:
# 纯base64数据默认为png
data = avatar_base64
ext = 'png'
# 生成文件名
file_id = self.generate_file_id()
filename = f"{userid}_{file_id}.{ext}"
file_path = self.avatar_path / filename
# 解码并保存文件
image_data = base64.b64decode(data)
async with aiofiles.open(file_path, 'wb') as f:
await f.write(image_data)
return filename
except Exception as e:
raise ValueError(f"保存头像失败: {str(e)}")
async def get_avatar(self, filename: str) -> Optional[bytes]:
"""
获取用户头像
:param filename: 文件名
:return: 图片数据
"""
if not filename:
return None
file_path = self.avatar_path / filename
if not file_path.exists():
return None
try:
async with aiofiles.open(file_path, 'rb') as f:
return await f.read()
except Exception:
return None
async def delete_avatar(self, filename: str) -> bool:
"""
删除用户头像
:param filename: 文件名
:return: 是否删除成功
"""
if not filename:
return True
file_path = self.avatar_path / filename
if file_path.exists():
try:
file_path.unlink()
return True
except Exception:
return False
return True
async def save_background(self, userid: str, background_base64: str) -> str:
"""
保存用户背景,删除旧背景
:param userid: 用户ID
:param background_base64: base64编码的背景数据
:return: 文件名
"""
if not background_base64:
return ""
try:
# 先清理旧的背景文件
await self.cleanup_user_files(userid, 'background')
# 解析base64数据
if background_base64.startswith('data:'):
# 处理data URI格式
header, data = background_base64.split(',', 1)
# 提取文件格式
if 'image/png' in header:
ext = 'png'
elif 'image/jpeg' in header or 'image/jpg' in header:
ext = 'jpg'
elif 'image/gif' in header:
ext = 'gif'
elif 'image/webp' in header:
ext = 'webp'
else:
ext = 'png' # 默认格式
else:
# 纯base64数据默认为png
data = background_base64
ext = 'png'
# 生成文件名
file_id = self.generate_file_id()
filename = f"{userid}_{file_id}.{ext}"
file_path = self.background_path / filename
# 解码并保存文件
image_data = base64.b64decode(data)
async with aiofiles.open(file_path, 'wb') as f:
await f.write(image_data)
return filename
except Exception as e:
raise ValueError(f"保存背景失败: {str(e)}")
async def get_background(self, filename: str) -> Optional[bytes]:
"""
获取用户背景
:param filename: 文件名
:return: 图片数据
"""
if not filename:
return None
file_path = self.background_path / filename
if not file_path.exists():
return None
try:
async with aiofiles.open(file_path, 'rb') as f:
return await f.read()
except Exception:
return None
async def delete_background(self, filename: str) -> bool:
"""
删除用户背景
:param filename: 文件名
:return: 是否删除成功
"""
if not filename:
return True
file_path = self.background_path / filename
if file_path.exists():
try:
file_path.unlink()
return True
except Exception:
return False
return True
async def save_settings(self, userid: str, settings: Dict[str, Any]) -> str:
"""
保存用户设置,删除旧设置
:param userid: 用户ID
:param settings: 设置字典
:return: 文件名
"""
try:
# 先清理旧的设置文件
await self.cleanup_user_files(userid, 'settings')
# 生成文件名
file_id = self.generate_file_id()
filename = f"{userid}_{file_id}.json"
file_path = self.settings_path / filename
# 保存JSON文件
async with aiofiles.open(file_path, 'w', encoding='utf-8') as f:
await f.write(json.dumps(settings, ensure_ascii=False, indent=2))
return filename
except Exception as e:
raise ValueError(f"保存设置失败: {str(e)}")
async def get_settings(self, filename: str) -> Optional[Dict[str, Any]]:
"""
获取用户设置
:param filename: 文件名
:return: 设置字典
"""
if not filename:
return None
file_path = self.settings_path / filename
if not file_path.exists():
return None
try:
async with aiofiles.open(file_path, 'r', encoding='utf-8') as f:
content = await f.read()
return json.loads(content)
except Exception:
return None
async def delete_settings(self, filename: str) -> bool:
"""
删除用户设置文件
:param filename: 文件名
:return: 是否删除成功
"""
if not filename:
return True
file_path = self.settings_path / filename
if file_path.exists():
try:
file_path.unlink()
return True
except Exception:
return False
return True
# 全局文件管理器实例
file_manager = FileManager()
def validate_settings(settings: Dict[str, Any]) -> bool:
"""
验证设置字典是否符合要求的格式
:param settings: 设置字典
:return: 是否有效
"""
required_fields = {
'theme': str,
'lightModeOpacity': (int, float),
'lightModeBrightness': (int, float),
'darkModeOpacity': (int, float),
'darkModeBrightness': (int, float),
'backgroundBlur': (int, float),
}
try:
# 检查所有必需字段是否存在且类型正确
for field, expected_type in required_fields.items():
if field not in settings:
return False
if not isinstance(settings[field], expected_type):
return False
# 验证数值范围0-1
numeric_fields = ['lightModeOpacity', 'lightModeBrightness',
'darkModeOpacity', 'darkModeBrightness', 'backgroundBlur']
for field in numeric_fields:
value = settings[field]
if not (0 <= value <= 1):
return False
# 验证主题值
valid_themes = ['light', 'dark', 'system', 'ThemeMode.light', 'ThemeMode.dark', 'ThemeMode.system']
if settings['theme'] not in valid_themes:
return False
return True
except Exception:
return False

370
utils/s3_client.py Normal file
View File

@@ -0,0 +1,370 @@
import asyncio
from typing import Optional, Dict, Any, BinaryIO, Union
from pathlib import Path
from loguru import logger
from config import config_manager
# 可选导入aioboto3
try:
import aioboto3
from botocore.exceptions import ClientError, NoCredentialsError
HAS_BOTO3 = True
except ImportError:
aioboto3 = None
ClientError = Exception
NoCredentialsError = Exception
HAS_BOTO3 = False
class S3Client:
"""异步S3客户端"""
def __init__(self):
self._session = None
self._client = None
self._config = None
if not HAS_BOTO3:
logger.warning("aioboto3未安装S3客户端功能不可用")
def _get_s3_config(self):
"""获取S3配置"""
if self._config is None:
self._config = config_manager.get_settings().s3
return self._config
async def _get_client(self):
"""获取S3客户端"""
if not HAS_BOTO3:
raise RuntimeError("aioboto3未安装无法使用S3客户端功能。请运行: pip install aioboto3")
if self._client is None:
config = self._get_s3_config()
# 验证必要的配置
if not config.access_key_id or not config.secret_access_key:
raise ValueError("S3 access_key_id 和 secret_access_key 不能为空")
if not config.bucket_name:
raise ValueError("S3 bucket_name 不能为空")
if self._session is None:
self._session = aioboto3.Session()
self._client = self._session.client(
's3',
aws_access_key_id=config.access_key_id,
aws_secret_access_key=config.secret_access_key,
endpoint_url=config.endpoint_url,
region_name=config.region_name,
use_ssl=config.use_ssl,
config=aioboto3.Config(signature_version=config.signature_version)
)
return self._client
async def upload_file(
self,
file_path: Union[str, Path],
key: str,
bucket: Optional[str] = None,
extra_args: Optional[Dict[str, Any]] = None
) -> bool:
"""
上传文件到S3
Args:
file_path: 本地文件路径
key: S3对象键名
bucket: 存储桶名称如果为None则使用配置中的默认bucket
extra_args: 额外参数如metadata, ACL等
Returns:
bool: 是否上传成功
"""
try:
config = self._get_s3_config()
bucket = bucket or config.bucket_name
if not bucket:
raise ValueError("bucket名称不能为空")
async with await self._get_client() as s3:
await s3.upload_file(
Filename=str(file_path),
Bucket=bucket,
Key=key,
ExtraArgs=extra_args or {}
)
logger.info(f"文件上传成功: {file_path} -> s3://{bucket}/{key}")
return True
except FileNotFoundError:
logger.error(f"文件不存在: {file_path}")
return False
except NoCredentialsError:
logger.error("S3凭据未配置或无效")
return False
except ClientError as e:
logger.error(f"S3客户端错误: {e}")
return False
except Exception as e:
logger.error(f"上传文件失败: {e}")
return False
async def download_file(
self,
key: str,
file_path: Union[str, Path],
bucket: Optional[str] = None
) -> bool:
"""
从S3下载文件
Args:
key: S3对象键名
file_path: 本地保存路径
bucket: 存储桶名称如果为None则使用配置中的默认bucket
Returns:
bool: 是否下载成功
"""
try:
config = self._get_s3_config()
bucket = bucket or config.bucket_name
if not bucket:
raise ValueError("bucket名称不能为空")
# 确保目标目录存在
Path(file_path).parent.mkdir(parents=True, exist_ok=True)
async with await self._get_client() as s3:
await s3.download_file(
Bucket=bucket,
Key=key,
Filename=str(file_path)
)
logger.info(f"文件下载成功: s3://{bucket}/{key} -> {file_path}")
return True
except ClientError as e:
if e.response['Error']['Code'] == 'NoSuchKey':
logger.error(f"S3对象不存在: s3://{bucket}/{key}")
else:
logger.error(f"S3客户端错误: {e}")
return False
except Exception as e:
logger.error(f"下载文件失败: {e}")
return False
async def upload_bytes(
self,
data: bytes,
key: str,
bucket: Optional[str] = None,
content_type: Optional[str] = None,
extra_args: Optional[Dict[str, Any]] = None
) -> bool:
"""
上传字节数据到S3
Args:
data: 要上传的字节数据
key: S3对象键名
bucket: 存储桶名称如果为None则使用配置中的默认bucket
content_type: 内容类型
extra_args: 额外参数
Returns:
bool: 是否上传成功
"""
try:
config = self._get_s3_config()
bucket = bucket or config.bucket_name
if not bucket:
raise ValueError("bucket名称不能为空")
extra_args = extra_args or {}
if content_type:
extra_args['ContentType'] = content_type
async with await self._get_client() as s3:
await s3.put_object(
Bucket=bucket,
Key=key,
Body=data,
**extra_args
)
logger.info(f"数据上传成功: s3://{bucket}/{key}")
return True
except Exception as e:
logger.error(f"上传数据失败: {e}")
return False
async def download_bytes(
self,
key: str,
bucket: Optional[str] = None
) -> Optional[bytes]:
"""
从S3下载字节数据
Args:
key: S3对象键名
bucket: 存储桶名称如果为None则使用配置中的默认bucket
Returns:
Optional[bytes]: 下载的字节数据失败时返回None
"""
try:
config = self._get_s3_config()
bucket = bucket or config.bucket_name
if not bucket:
raise ValueError("bucket名称不能为空")
async with await self._get_client() as s3:
response = await s3.get_object(Bucket=bucket, Key=key)
async with response['Body'] as stream:
data = await stream.read()
logger.info(f"数据下载成功: s3://{bucket}/{key}")
return data
except ClientError as e:
if e.response['Error']['Code'] == 'NoSuchKey':
logger.error(f"S3对象不存在: s3://{bucket}/{key}")
else:
logger.error(f"S3客户端错误: {e}")
return None
except Exception as e:
logger.error(f"下载数据失败: {e}")
return None
async def delete_object(
self,
key: str,
bucket: Optional[str] = None
) -> bool:
"""
删除S3对象
Args:
key: S3对象键名
bucket: 存储桶名称如果为None则使用配置中的默认bucket
Returns:
bool: 是否删除成功
"""
try:
config = self._get_s3_config()
bucket = bucket or config.bucket_name
if not bucket:
raise ValueError("bucket名称不能为空")
async with await self._get_client() as s3:
await s3.delete_object(Bucket=bucket, Key=key)
logger.info(f"对象删除成功: s3://{bucket}/{key}")
return True
except Exception as e:
logger.error(f"删除对象失败: {e}")
return False
async def list_objects(
self,
prefix: str = "",
bucket: Optional[str] = None,
max_keys: int = 1000
) -> list:
"""
列出S3对象
Args:
prefix: 对象键前缀
bucket: 存储桶名称如果为None则使用配置中的默认bucket
max_keys: 最大返回对象数量
Returns:
list: 对象列表
"""
try:
config = self._get_s3_config()
bucket = bucket or config.bucket_name
if not bucket:
raise ValueError("bucket名称不能为空")
async with await self._get_client() as s3:
response = await s3.list_objects_v2(
Bucket=bucket,
Prefix=prefix,
MaxKeys=max_keys
)
objects = response.get('Contents', [])
logger.info(f"列出对象成功: s3://{bucket}/{prefix}* ({len(objects)}个对象)")
return objects
except Exception as e:
logger.error(f"列出对象失败: {e}")
return []
async def object_exists(
self,
key: str,
bucket: Optional[str] = None
) -> bool:
"""
检查S3对象是否存在
Args:
key: S3对象键名
bucket: 存储桶名称如果为None则使用配置中的默认bucket
Returns:
bool: 对象是否存在
"""
try:
config = self._get_s3_config()
bucket = bucket or config.bucket_name
if not bucket:
raise ValueError("bucket名称不能为空")
async with await self._get_client() as s3:
await s3.head_object(Bucket=bucket, Key=key)
return True
except ClientError as e:
if e.response['Error']['Code'] == '404':
return False
else:
logger.error(f"检查对象存在性失败: {e}")
return False
except Exception as e:
logger.error(f"检查对象存在性失败: {e}")
return False
async def close(self):
"""关闭S3客户端"""
if self._client:
await self._client.close()
self._client = None
if self._session:
await self._session.close()
self._session = None
# 全局S3客户端实例
s3_client = S3Client()

2876
yarn.lock generated Normal file

File diff suppressed because it is too large Load Diff