🎉初次提交
This commit is contained in:
10
.editorconfig
Normal file
10
.editorconfig
Normal 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
121
.gitattributes
vendored
Normal 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
109
.github/workflows/deploy-docs.yml
vendored
Normal 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
422
.gitignore
vendored
Normal 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
31
.markdownlint.json
Normal 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
21
LICENSE
Normal 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
193
README.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# LoveACE - 财大教务自动化工具
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="logo.jpg" alt="LoveAC Logo" width="120" height="120" />
|
||||
|
||||
**简化学生教务操作,提高使用效率**
|
||||
|
||||
[](LICENSE)
|
||||
[](https://python.org)
|
||||
[](https://fastapi.tiangolo.com)
|
||||
[]
|
||||
|
||||
|
||||
</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
12
config/__init__.py
Normal 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
68
config/logger.py
Normal 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
177
config/manager.py
Normal 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
151
config/models.py
Normal 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
4
database/__init__.py
Normal 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
6
database/base.py
Normal 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
79
database/creator.py
Normal 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
0
database/train_plan.py
Normal file
52
database/user.py
Normal file
52
database/user.py
Normal 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())
|
||||
200
docs/.vitepress/components/SwaggerUI.vue
Normal file
200
docs/.vitepress/components/SwaggerUI.vue
Normal 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
75
docs/.vitepress/config.ts
Normal 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
|
||||
}
|
||||
})
|
||||
29
docs/.vitepress/theme/custom.css
Normal file
29
docs/.vitepress/theme/custom.css
Normal 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;
|
||||
}
|
||||
}
|
||||
17
docs/.vitepress/theme/index.ts
Normal file
17
docs/.vitepress/theme/index.ts
Normal 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
18
docs/api/index.md
Normal 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
232
docs/config.md
Normal 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
5
docs/contributing.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 贡献指南
|
||||
|
||||
感谢您对LoveACE项目的关注!我们欢迎所有形式的贡献,包括但不限于代码贡献、文档改进、问题报告和功能建议。
|
||||
|
||||
## In Progress
|
||||
5
docs/deploy.md
Normal file
5
docs/deploy.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 部署指南
|
||||
|
||||
本指南介绍如何在生产环境中部署LoveACE教务系统自动化工具。
|
||||
|
||||
## In Progress
|
||||
119
docs/disclaimer.md
Normal file
119
docs/disclaimer.md
Normal 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
123
docs/getting-started.md
Normal 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
91
docs/index.md
Normal 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
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
2758
docs/public/openapi.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
docs/public/swagger-ui-bundle.js
Normal file
2
docs/public/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
2
docs/public/swagger-ui-standalone-preset.js
Normal file
2
docs/public/swagger-ui-standalone-preset.js
Normal file
File diff suppressed because one or more lines are too long
3
docs/public/swagger-ui.css
Normal file
3
docs/public/swagger-ui.css
Normal file
File diff suppressed because one or more lines are too long
70
main.py
Normal file
70
main.py
Normal 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
2758
openapi.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal 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"
|
||||
}
|
||||
287
provider/aufe/aac/__init__.py
Normal file
287
provider/aufe/aac/__init__.py
Normal 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("系统错误,已进行多次重试")
|
||||
|
||||
66
provider/aufe/aac/depends.py
Normal file
66
provider/aufe/aac/depends.py
Normal 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
105
provider/aufe/aac/model.py
Normal 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
1176
provider/aufe/client.py
Normal file
File diff suppressed because it is too large
Load Diff
1328
provider/aufe/jwc/__init__.py
Normal file
1328
provider/aufe/jwc/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
36
provider/aufe/jwc/depends.py
Normal file
36
provider/aufe/jwc/depends.py
Normal 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
296
provider/aufe/jwc/model.py
Normal 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="学期ID,如:2024-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="成绩记录列表")
|
||||
0
provider/aufe/labor_club/__init__.py
Normal file
0
provider/aufe/labor_club/__init__.py
Normal file
0
provider/aufe/labor_club/depends.py
Normal file
0
provider/aufe/labor_club/depends.py
Normal file
0
provider/aufe/labor_club/model.py
Normal file
0
provider/aufe/labor_club/model.py
Normal file
89
provider/loveac/authme.py
Normal file
89
provider/loveac/authme.py
Normal 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())
|
||||
0
provider/loveac/usersync.py
Normal file
0
provider/loveac/usersync.py
Normal file
33
pyproject.toml
Normal file
33
pyproject.toml
Normal 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
77
router/aac/__init__.py
Normal 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
16
router/aac/model.py
Normal 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
100
router/common_model.py
Normal 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
118
router/invite/__init__.py
Normal 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
39
router/invite/model.py
Normal 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
605
router/jwc/__init__.py
Normal 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
670
router/jwc/evaluate.py
Normal 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()
|
||||
95
router/jwc/evaluate_model.py
Normal file
95
router/jwc/evaluate_model.py
Normal 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
122
router/jwc/model.py
Normal 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="学期ID,如:2024-2025-2-1")
|
||||
course_code: str = Field("", description="课程代码(可选,用于筛选)")
|
||||
course_name: str = Field("", description="课程名称(可选,用于筛选)")
|
||||
page_num: int = Field(1, description="页码,默认为1", ge=1)
|
||||
page_size: int = Field(50, description="每页大小,默认为50", ge=1, le=100)
|
||||
|
||||
|
||||
class AllTermsResponse(BaseResponse[Dict[str, str]]):
|
||||
"""所有学期信息响应"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TermScoreAPIResponse(BaseResponse[TermScoreResponse]):
|
||||
"""学期成绩响应"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ==================== 课表相关响应模型 ====================
|
||||
|
||||
|
||||
class TimeSlot(BaseModel):
|
||||
"""时间段模型"""
|
||||
|
||||
session: int = Field(..., description="节次")
|
||||
session_name: str = Field(..., description="节次名称")
|
||||
start_time: str = Field(..., description="开始时间,格式:HHMM")
|
||||
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
109
router/login/__init__.py
Normal 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
31
router/login/model.py
Normal 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
242
router/user/__init__.py
Normal 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
79
router/user/model.py
Normal 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
6
utils/__init__.py
Normal 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
322
utils/file_manager.py
Normal 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
370
utils/s3_client.py
Normal 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()
|
||||
Reference in New Issue
Block a user