📚新增 培养方案 查询模块

This commit is contained in:
2025-09-07 16:46:31 +08:00
parent b51a6371e7
commit 6b90c6d7bb
9 changed files with 1111 additions and 378 deletions

90
pdm.lock generated
View File

@@ -5,7 +5,7 @@
groups = ["default", "dev"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
content_hash = "sha256:906aa83c21919f8fa22d06361e5108ffb44a5c16d5e18ae54d1525c4601ba499"
content_hash = "sha256:8a2a4fef253e4b00e323735a087cc66549c16d8bafbde85137037308c690618e"
[[metadata.targets]]
requires_python = "==3.12.*"
@@ -447,6 +447,55 @@ files = [
{file = "frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f"},
]
[[package]]
name = "granian"
version = "2.5.2"
requires_python = ">=3.9"
summary = "A Rust HTTP server for Python applications"
groups = ["default"]
dependencies = [
"click>=8.0.0",
]
files = [
{file = "granian-2.5.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:47b08d59491d75c1c0504cf982ea6a3594c58cc670436d9b6e4bcd625ab235e8"},
{file = "granian-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ec4982a68d22bb12f68c372ecaba680ca352d80e678ff8bfc1eab794267920c7"},
{file = "granian-2.5.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d356158ded2a6bb7246d5e0aa0730466ffad4f5eeac1e0067998a455de19d08"},
{file = "granian-2.5.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7bdd088bc3bcd16df6f9507440baa51aadd4a5c8b6bdcd3b0338eddb9aa1d98f"},
{file = "granian-2.5.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e809d0772a78e7f281be4e7a2ebd75ea84de646aacbe441cfaf37b73e3ab9a0d"},
{file = "granian-2.5.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c4884ded8219df93b5b1c708b55b2fa7bf060f31cb8833838cace48dcf8265f4"},
{file = "granian-2.5.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a84551eba12a8f1ac5433675ade603d987ff6523653e623ee0fe47e25ca846b2"},
{file = "granian-2.5.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fccfd74be22962afc3b241c52b5ee159378fb1bc1c39738386d82959b19d83e0"},
{file = "granian-2.5.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bdfef2a50a907cf6d6b8af6f06d1367a682a09dd4ecfd2a707e901494d2644a9"},
{file = "granian-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:cf1b83cb8b406ab37d484f8272f9d9efd28fbdf0a454f52b3be8c39dcf7425c8"},
{file = "granian-2.5.2.tar.gz", hash = "sha256:fdaa832a99745f74ca303ee587ae9ad5e3f0ab4a6e5a5d509619faabc7e5b1f0"},
]
[[package]]
name = "granian"
version = "2.5.2"
extras = ["pname", "uvloop"]
requires_python = ">=3.9"
summary = "A Rust HTTP server for Python applications"
groups = ["default"]
dependencies = [
"granian==2.5.2",
"setproctitle~=1.3.3",
"uvloop>=0.18.0; platform_python_implementation == \"CPython\" and sys_platform != \"win32\"",
]
files = [
{file = "granian-2.5.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:47b08d59491d75c1c0504cf982ea6a3594c58cc670436d9b6e4bcd625ab235e8"},
{file = "granian-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ec4982a68d22bb12f68c372ecaba680ca352d80e678ff8bfc1eab794267920c7"},
{file = "granian-2.5.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d356158ded2a6bb7246d5e0aa0730466ffad4f5eeac1e0067998a455de19d08"},
{file = "granian-2.5.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7bdd088bc3bcd16df6f9507440baa51aadd4a5c8b6bdcd3b0338eddb9aa1d98f"},
{file = "granian-2.5.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e809d0772a78e7f281be4e7a2ebd75ea84de646aacbe441cfaf37b73e3ab9a0d"},
{file = "granian-2.5.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c4884ded8219df93b5b1c708b55b2fa7bf060f31cb8833838cace48dcf8265f4"},
{file = "granian-2.5.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a84551eba12a8f1ac5433675ade603d987ff6523653e623ee0fe47e25ca846b2"},
{file = "granian-2.5.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fccfd74be22962afc3b241c52b5ee159378fb1bc1c39738386d82959b19d83e0"},
{file = "granian-2.5.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bdfef2a50a907cf6d6b8af6f06d1367a682a09dd4ecfd2a707e901494d2644a9"},
{file = "granian-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:cf1b83cb8b406ab37d484f8272f9d9efd28fbdf0a454f52b3be8c39dcf7425c8"},
{file = "granian-2.5.2.tar.gz", hash = "sha256:fdaa832a99745f74ca303ee587ae9ad5e3f0ab4a6e5a5d509619faabc7e5b1f0"},
]
[[package]]
name = "greenlet"
version = "3.2.2"
@@ -885,6 +934,28 @@ files = [
{file = "s3transfer-0.13.1.tar.gz", hash = "sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf"},
]
[[package]]
name = "setproctitle"
version = "1.3.6"
requires_python = ">=3.8"
summary = "A Python module to customize the process title"
groups = ["default"]
files = [
{file = "setproctitle-1.3.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:af44bb7a1af163806bbb679eb8432fa7b4fb6d83a5d403b541b675dcd3798638"},
{file = "setproctitle-1.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cca16fd055316a48f0debfcbfb6af7cea715429fc31515ab3fcac05abd527d8"},
{file = "setproctitle-1.3.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea002088d5554fd75e619742cefc78b84a212ba21632e59931b3501f0cfc8f67"},
{file = "setproctitle-1.3.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb465dd5825356c1191a038a86ee1b8166e3562d6e8add95eec04ab484cfb8a2"},
{file = "setproctitle-1.3.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2c8e20487b3b73c1fa72c56f5c89430617296cd380373e7af3a538a82d4cd6d"},
{file = "setproctitle-1.3.6-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d6252098e98129a1decb59b46920d4eca17b0395f3d71b0d327d086fefe77d"},
{file = "setproctitle-1.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cf355fbf0d4275d86f9f57be705d8e5eaa7f8ddb12b24ced2ea6cbd68fdb14dc"},
{file = "setproctitle-1.3.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e288f8a162d663916060beb5e8165a8551312b08efee9cf68302687471a6545d"},
{file = "setproctitle-1.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b2e54f4a2dc6edf0f5ea5b1d0a608d2af3dcb5aa8c8eeab9c8841b23e1b054fe"},
{file = "setproctitle-1.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b6f4abde9a2946f57e8daaf1160b2351bcf64274ef539e6675c1d945dbd75e2a"},
{file = "setproctitle-1.3.6-cp312-cp312-win32.whl", hash = "sha256:db608db98ccc21248370d30044a60843b3f0f3d34781ceeea67067c508cd5a28"},
{file = "setproctitle-1.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:082413db8a96b1f021088e8ec23f0a61fec352e649aba20881895815388b66d3"},
{file = "setproctitle-1.3.6.tar.gz", hash = "sha256:c9f32b96c700bb384f33f7cf07954bb609d35dd82752cef57fb2ee0968409169"},
]
[[package]]
name = "six"
version = "1.17.0"
@@ -1063,6 +1134,23 @@ files = [
{file = "uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328"},
]
[[package]]
name = "uvloop"
version = "0.21.0"
requires_python = ">=3.8.0"
summary = "Fast implementation of asyncio event loop on top of libuv"
groups = ["default"]
marker = "platform_python_implementation == \"CPython\" and sys_platform != \"win32\""
files = [
{file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c"},
{file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2"},
{file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d"},
{file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc"},
{file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb"},
{file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f"},
{file = "uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3"},
]
[[package]]
name = "win32-setctime"
version = "1.2.0"

View File

@@ -1,4 +1,5 @@
import re
import json
import asyncio
from typing import List, Optional, Dict
from loguru import logger
@@ -20,6 +21,16 @@ from provider.aufe.jwc.model import (
ErrorAcademicInfo,
ErrorTrainingPlanInfo,
)
from provider.aufe.jwc.plan_completion_model import (
PlanCompletionInfo,
PlanCompletionCategory,
PlanCompletionCourse,
ErrorPlanCompletionInfo,
)
from provider.aufe.jwc.semester_week_model import (
SemesterWeekInfo,
ErrorSemesterWeekInfo,
)
from provider.aufe.client import (
AUFEConnection,
aufe_config_global,
@@ -40,6 +51,7 @@ class JWCConfig:
ENDPOINTS = {
"academic_info": "/student/integratedQuery/scoreQuery/index",
"training_plan": "/student/integratedQuery/planCompletion/index",
"plan_completion": "/student/integratedQuery/planCompletion/index",
"course_selection_status": "/main/checkSelectCourseStatus?sf_request_type=ajax",
"evaluation_token": "/student/teachingEvaluation/evaluation/index",
"course_list": "/student/teachingEvaluation/teachingEvaluation/search?sf_request_type=ajax",
@@ -904,7 +916,7 @@ class JWCClient:
"""
try:
url = f"{self.base_url}/student/integratedQuery/scoreQuery/allTermScores/index"
url = f"{self.base_url}/student/courseSelect/calendarSemesterCurriculum/index"
headers = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
@@ -928,7 +940,7 @@ class JWCClient:
soup = BeautifulSoup(response.text, "html.parser")
# 查找学期选择下拉框
select_element = soup.find("select", {"id": "zxjxjhh"})
select_element = soup.find("select", {"id": "planCode"})
if not select_element:
logger.error("未找到学期选择框")
return {}
@@ -1386,3 +1398,379 @@ class JWCClient:
except Exception as e:
logger.error(f"获取处理后的课表数据异常: {str(e)}")
return None
# ==================== 培养方案完成情况相关方法 ====================
@activity_tracker
@retry_async()
async def fetch_plan_completion_info(self) -> PlanCompletionInfo:
"""
获取培养方案完成情况信息,使用重试机制
Returns:
PlanCompletionInfo: 培养方案完成情况信息,失败时返回错误模型
"""
def _create_error_completion_info(error_msg: str = "请求失败,请稍后重试") -> ErrorPlanCompletionInfo:
"""创建错误培养方案完成情况信息"""
return ErrorPlanCompletionInfo(
plan_name=error_msg,
major="请求失败",
grade="",
total_categories=-1,
total_courses=-1,
passed_courses=-1,
failed_courses=-1,
unread_courses=-1
)
try:
logger.info("开始获取培养方案完成情况信息")
headers = self._get_default_headers()
# 请求培养方案完成情况页面
response = await self.vpn_connection.requester().get(
f"{self.base_url}/student/integratedQuery/planCompletion/index",
headers=headers,
follow_redirects=True,
)
if response.status_code != 200:
raise AUFEConnectionError(f"获取培养方案完成情况页面失败,状态码: {response.status_code}")
html_content = response.text
# 使用BeautifulSoup解析HTML
soup = BeautifulSoup(html_content, "html.parser")
# 提取培养方案名称
plan_name = ""
# 查找包含"培养方案"的h4标签
h4_elements = soup.find_all("h4")
for h4 in h4_elements:
text = h4.get_text(strip=True) if h4 else ""
if "培养方案" in text:
plan_name = text
logger.info(f"找到培养方案标题: {plan_name}")
break
# 解析专业和年级信息
major = ""
grade = ""
if plan_name:
grade_match = re.search(r"(\d{4})级", plan_name)
if grade_match:
grade = grade_match.group(1)
major_match = re.search(r"\d{4}级(.+?)本科", plan_name)
if major_match:
major = major_match.group(1)
# 查找zTree数据
ztree_data = []
# 在script标签中查找zTree初始化数据
scripts = soup.find_all("script")
for script in scripts:
try:
script_text = script.get_text() if script else ""
if "$.fn.zTree.init" in script_text and "flagId" in script_text:
logger.info("找到包含zTree初始化的script标签")
# 提取zTree数据
# 尝试多种模式匹配
patterns = [
r'\$\.fn\.zTree\.init\(\$\("#treeDemo"\),\s*setting,\s*(\[.*?\])\s*\);',
r'\.zTree\.init\([^,]+,\s*[^,]+,\s*(\[.*?\])\s*\);',
r'init\(\$\("#treeDemo"\)[^,]*,\s*[^,]*,\s*(\[.*?\])',
]
json_part = None
for pattern in patterns:
match = re.search(pattern, script_text, re.DOTALL)
if match:
json_part = match.group(1)
logger.info(f"使用模式匹配成功提取zTree数据: {len(json_part)}字符")
break
if json_part:
# 清理和修复JSON格式
# 移除JavaScript注释和多余的逗号
json_part = re.sub(r'//.*?\n', '\n', json_part)
json_part = re.sub(r'/\*.*?\*/', '', json_part, flags=re.DOTALL)
json_part = re.sub(r',\s*}', '}', json_part)
json_part = re.sub(r',\s*]', ']', json_part)
try:
ztree_data = json.loads(json_part)
logger.info(f"JSON解析成功{len(ztree_data)}个节点")
break
except json.JSONDecodeError as e:
logger.warning(f"JSON解析失败: {str(e)}")
# 如果JSON解析失败不使用手动解析直接跳过
continue
else:
logger.warning("未能通过模式匹配提取zTree数据")
continue
except Exception:
continue
if not ztree_data:
logger.warning("未找到有效的zTree数据")
# 输出调试信息
logger.debug(f"HTML内容长度: {len(html_content)}")
logger.debug(f"找到的script标签数量: {len(soup.find_all('script'))}")
# 检查是否包含关键词
contains_ztree = "zTree" in html_content
contains_flagid = "flagId" in html_content
contains_plan = "培养方案" in html_content
logger.debug(f"HTML包含关键词: zTree={contains_ztree}, flagId={contains_flagid}, 培养方案={contains_plan}")
if contains_plan:
logger.warning("检测到培养方案内容但zTree数据解析失败可能页面结构已变化")
else:
logger.warning("未检测到培养方案相关内容,可能需要重新登录或检查访问权限")
return _create_error_completion_info("未找到培养方案数据,请检查登录状态或访问权限")
# 解析zTree数据构建分类和课程信息
completion_info = self._build_completion_info_from_ztree(
ztree_data, plan_name, major, grade
)
logger.info(
f"培养方案完成情况获取成功: {completion_info.plan_name}, "
f"总分类数: {completion_info.total_categories}, "
f"总课程数: {completion_info.total_courses}"
)
return completion_info
except (AUFEConnectionError, AUFEParseError) as e:
logger.error(f"获取培养方案完成情况失败: {str(e)}")
return _create_error_completion_info(f"请求失败: {str(e)}")
except Exception as e:
logger.error(f"获取培养方案完成情况异常: {str(e)}")
return _create_error_completion_info()
def _build_completion_info_from_ztree(
self,
ztree_data: List[dict],
plan_name: str,
major: str,
grade: str
) -> PlanCompletionInfo:
"""从zTree数据构建培养方案完成情况信息"""
try:
# 按层级组织数据
nodes_by_id = {node["id"]: node for node in ztree_data}
root_categories = []
# 统计根分类和所有节点信息,用于调试
all_parent_ids = set()
root_nodes = []
for node in ztree_data:
parent_id = node.get("pId", "")
all_parent_ids.add(parent_id)
# 根分类的判断条件pId为"-1"这是zTree中真正的根节点标识
# 从HTML示例可以看出真正的根分类的pId是"-1"
is_root_category = parent_id == "-1"
if is_root_category:
root_nodes.append(node)
logger.info(f"zTree数据分析: 总节点数={len(ztree_data)}, 根节点数={len(root_nodes)}, 不同父ID数={len(all_parent_ids)}")
logger.debug(f"所有父ID: {sorted(all_parent_ids)}")
# 构建分类树
for node in root_nodes:
category = PlanCompletionCategory.from_ztree_node(node)
self._populate_category_children(category, node["id"], nodes_by_id)
root_categories.append(category)
logger.debug(f"创建根分类: {category.category_name} (ID: {node['id']})")
# 创建完成情况信息
completion_info = PlanCompletionInfo(
plan_name=plan_name,
major=major,
grade=grade,
categories=root_categories,
total_categories=0,
total_courses=0,
passed_courses=0,
failed_courses=0,
unread_courses=0
)
# 计算统计信息
completion_info.calculate_statistics()
return completion_info
except Exception as e:
logger.error(f"构建培养方案完成情况信息异常: {str(e)}")
return ErrorPlanCompletionInfo(
plan_name="解析失败",
major="解析失败",
grade="",
total_categories=-1,
total_courses=-1,
passed_courses=-1,
failed_courses=-1,
unread_courses=-1
)
def _populate_category_children(
self,
category: PlanCompletionCategory,
category_id: str,
nodes_by_id: dict
):
"""填充分类的子分类和课程(支持多层嵌套)"""
try:
children_count = 0
subcategory_count = 0
course_count = 0
for node in nodes_by_id.values():
if node.get("pId") == category_id:
children_count += 1
flag_type = node.get("flagType", "")
if flag_type in ["001", "002"]: # 分类或子分类
subcategory = PlanCompletionCategory.from_ztree_node(node)
# 递归处理子项,支持多层嵌套
self._populate_category_children(subcategory, node["id"], nodes_by_id)
category.subcategories.append(subcategory)
subcategory_count += 1
elif flag_type == "kch": # 课程
course = PlanCompletionCourse.from_ztree_node(node)
category.courses.append(course)
course_count += 1
else:
# 处理其他类型的节点,也可能是分类
# 根据是否有子节点来判断是分类还是课程
has_children = any(n.get("pId") == node["id"] for n in nodes_by_id.values())
if has_children:
# 有子节点,当作分类处理
subcategory = PlanCompletionCategory.from_ztree_node(node)
self._populate_category_children(subcategory, node["id"], nodes_by_id)
category.subcategories.append(subcategory)
subcategory_count += 1
else:
# 无子节点,当作课程处理
course = PlanCompletionCourse.from_ztree_node(node)
category.courses.append(course)
course_count += 1
if children_count > 0:
logger.debug(f"分类 '{category.category_name}' (ID: {category_id}) 的子项: 总数={children_count}, 子分类={subcategory_count}, 课程={course_count}")
except Exception as e:
logger.error(f"填充分类子项异常: {str(e)}")
logger.error(f"异常节点信息: category_id={category_id}, 错误详情: {str(e)}")
async def fetch_semester_week_info(self) -> SemesterWeekInfo:
"""
获取当前学期周数信息
Returns:
SemesterWeekInfo: 学期周数信息,失败时返回错误模型
"""
def _create_error_week_info(error_msg: str = "请求失败,请稍后重试") -> ErrorSemesterWeekInfo:
"""创建错误学期周数信息"""
return ErrorSemesterWeekInfo(
academic_year=error_msg,
semester="请求失败",
week_number=-1,
is_end=False,
weekday="请求失败",
raw_text=""
)
try:
logger.info("开始获取学期周数信息")
headers = self._get_default_headers()
# 请求主页以获取当前学期周数信息
response = await self.vpn_connection.requester().get(
f"{self.base_url}/",
headers=headers,
follow_redirects=True,
)
if response.status_code != 200:
raise AUFEConnectionError(f"获取学期周数信息页面失败,状态码: {response.status_code}")
html_content = response.text
# 使用BeautifulSoup解析HTML
soup = BeautifulSoup(html_content, "html.parser")
# 查找包含学期周数信息的元素
# 使用CSS选择器查找
calendar_element = soup.select_one("#navbar-container > div.navbar-buttons.navbar-header.pull-right > ul > li.light-red > a")
if not calendar_element:
# 如果CSS选择器失败尝试其他方法
# 查找包含"第X周"的元素
potential_elements = soup.find_all("a", class_="dropdown-toggle")
calendar_element = None
for element in potential_elements:
text = element.get_text(strip=True) if element else ""
if "" in text and "" in text:
calendar_element = element
break
# 如果还是找不到,尝试查找任何包含学期信息的元素
if not calendar_element:
all_elements = soup.find_all(text=re.compile(r'\d{4}-\d{4}.*第\d+周'))
if all_elements:
# 找到包含学期信息的文本,查找其父元素
for text_node in all_elements:
parent = text_node.parent
if parent:
calendar_element = parent
break
if not calendar_element:
logger.warning("未找到学期周数信息元素")
# 尝试在整个页面中搜索学期信息模式
semester_pattern = re.search(r'(\d{4}-\d{4})\s*(春|秋|夏)?\s*第(\d+)周\s*(星期[一二三四五六日天])?', html_content)
if semester_pattern:
calendar_text = semester_pattern.group(0)
logger.info(f"通过正则表达式找到学期信息: {calendar_text}")
else:
logger.debug(f"HTML内容长度: {len(html_content)}")
logger.debug("未检测到学期周数相关内容,可能需要重新登录或检查访问权限")
return _create_error_week_info("未找到学期周数信息,请检查登录状态或访问权限")
else:
# 提取文本内容
calendar_text = calendar_element.get_text(strip=True)
logger.info(f"找到学期周数信息: {calendar_text}")
# 解析学期周数信息
week_info = SemesterWeekInfo.from_calendar_text(calendar_text)
logger.info(
f"学期周数信息获取成功: {week_info.academic_year} {week_info.semester} "
f"{week_info.week_number}{week_info.weekday}, 是否结束: {week_info.is_end}"
)
return week_info
except (AUFEConnectionError, AUFEParseError) as e:
logger.error(f"获取学期周数信息失败: {str(e)}")
return _create_error_week_info(f"请求失败: {str(e)}")
except Exception as e:
logger.error(f"获取学期周数信息异常: {str(e)}")
return _create_error_week_info()

View File

@@ -0,0 +1,342 @@
from typing import List, Optional
from pydantic import BaseModel, Field
import re
class PlanCompletionCourse(BaseModel):
"""培养方案课程完成情况"""
flag_id: str = Field("", description="课程标识ID")
flag_type: str = Field("", description="节点类型001=分类, 002=子分类, kch=课程")
course_code: str = Field("", description="课程代码,如 PDA2121005")
course_name: str = Field("", description="课程名称")
is_passed: bool = Field(False, description="是否通过基于CSS图标解析")
status_description: str = Field("", description="状态描述:未修读/已通过/未通过")
credits: Optional[float] = Field(None, description="学分")
score: Optional[str] = Field(None, description="成绩")
exam_date: Optional[str] = Field(None, description="考试日期")
course_type: str = Field("", description="课程类型:必修/任选等")
parent_id: str = Field("", description="父节点ID")
level: int = Field(0, description="层级0=根分类1=子分类2=课程")
@classmethod
def from_ztree_node(cls, node: dict) -> "PlanCompletionCourse":
"""从 zTree 节点数据创建课程对象"""
# 解析name字段中的信息
name = node.get("name", "")
flag_id = node.get("flagId", "")
flag_type = node.get("flagType", "")
parent_id = node.get("pId", "")
# 根据CSS图标判断通过状态
is_passed = False
status_description = "未修读"
if "fa-smile-o fa-1x green" in name:
is_passed = True
status_description = "已通过"
elif "fa-meh-o fa-1x light-grey" in name:
is_passed = False
status_description = "未修读"
elif "fa-frown-o fa-1x red" in name:
is_passed = False
status_description = "未通过"
# 从name中提取纯文本内容
# 移除HTML标签和图标
clean_name = re.sub(r'<[^>]*>', '', name)
clean_name = re.sub(r'&nbsp;', ' ', clean_name).strip()
# 解析课程信息
course_code = ""
course_name = ""
credits = None
score = None
exam_date = None
course_type = ""
if flag_type == "kch": # 课程节点
# 解析课程代码:[PDA2121005]形势与政策
code_match = re.search(r'\[([^\]]+)\]', clean_name)
if code_match:
course_code = code_match.group(1)
remaining_text = clean_name.split(']', 1)[1].strip()
# 解析学分信息:[0.3学分]
credit_match = re.search(r'\[([0-9.]+)学分\]', remaining_text)
if credit_match:
credits = float(credit_match.group(1))
remaining_text = re.sub(r'\[[0-9.]+学分\]', '', remaining_text).strip()
# 处理复杂的括号内容
# 例如85.0(20250626 成绩,都没把日期解析上,中国近现代史纲要)
# 或者:(任选,87.0(20250119))
# 找到最外层的括号
paren_match = re.search(r'\(([^)]+(?:\([^)]*\)[^)]*)*)\)$', remaining_text)
if paren_match:
paren_content = paren_match.group(1)
course_name_candidate = re.sub(r'\([^)]+(?:\([^)]*\)[^)]*)*\)$', '', remaining_text).strip()
# 检查括号内容的格式
if '' in paren_content:
# 处理包含中文逗号的复杂格式
parts = paren_content.split('')
# 最后一部分可能是课程名
last_part = parts[-1].strip()
if re.search(r'[\u4e00-\u9fff]', last_part) and len(last_part) > 1:
# 最后一部分包含中文,很可能是真正的课程名
course_name = last_part
# 从前面的部分提取成绩和其他信息
remaining_parts = ''.join(parts[:-1])
# 提取成绩
score_match = re.search(r'([0-9.]+)', remaining_parts)
if score_match:
score = score_match.group(1)
# 提取日期
date_match = re.search(r'(\d{8})', remaining_parts)
if date_match:
exam_date = date_match.group(1)
# 提取课程类型(如果有的话)
if len(parts) > 2:
potential_type = parts[0].strip()
if not re.search(r'[0-9.]', potential_type):
course_type = potential_type
else:
# 最后一部分不是课程名,使用括号外的内容
course_name = course_name_candidate if course_name_candidate else "未知课程"
# 从整个括号内容提取信息
score_match = re.search(r'([0-9.]+)', paren_content)
if score_match:
score = score_match.group(1)
date_match = re.search(r'(\d{8})', paren_content)
if date_match:
exam_date = date_match.group(1)
elif ',' in paren_content:
# 处理标准格式:(任选,87.0(20250119))
type_score_parts = paren_content.split(',', 1)
if len(type_score_parts) == 2:
course_type = type_score_parts[0].strip()
score_info = type_score_parts[1].strip()
# 解析成绩和日期
score_date_match = re.search(r'([0-9.]+)\((\d{8})\)', score_info)
if score_date_match:
score = score_date_match.group(1)
exam_date = score_date_match.group(2)
else:
score_match = re.search(r'([0-9.]+)', score_info)
if score_match:
score = score_match.group(1)
# 使用括号外的内容作为课程名
course_name = course_name_candidate if course_name_candidate else "未知课程"
else:
# 括号内只有简单内容
course_name = course_name_candidate if course_name_candidate else "未知课程"
# 尝试从括号内容提取成绩
score_match = re.search(r'([0-9.]+)', paren_content)
if score_match:
score = score_match.group(1)
# 尝试提取日期
date_match = re.search(r'(\d{8})', paren_content)
if date_match:
exam_date = date_match.group(1)
else:
# 没有括号,直接使用剩余文本作为课程名
course_name = remaining_text
# 清理课程名
course_name = re.sub(r'\s+', ' ', course_name).strip()
course_name = course_name.strip(',。.')
# 如果课程名为空或太短,尝试从原始名称提取
if not course_name or len(course_name) < 2:
chinese_match = re.search(r'[\u4e00-\u9fff]+(?:[\u4e00-\u9fff\s]*[\u4e00-\u9fff]+)*', clean_name)
if chinese_match:
course_name = chinese_match.group(0).strip()
else:
course_name = clean_name
else:
# 分类节点
course_name = clean_name
# 清理分类名称中的多余括号,但保留重要信息
# 如果是包含学分信息的分类名,保留学分信息
if not re.search(r'学分', course_name):
# 删除分类名称中的统计信息括号,如 "通识通修(已完成20.0/需要20.0)"
course_name = re.sub(r'\([^)]*完成[^)]*\)', '', course_name).strip()
# 删除其他可能的统计括号
course_name = re.sub(r'\([^)]*\d+\.\d+/[^)]*\)', '', course_name).strip()
# 清理多余的空格和空括号
course_name = re.sub(r'\(\s*\)', '', course_name).strip()
course_name = re.sub(r'\s+', ' ', course_name).strip()
# 确定层级
level = 0
if flag_type == "002":
level = 1
elif flag_type == "kch":
level = 2
return cls(
flag_id=flag_id,
flag_type=flag_type,
course_code=course_code,
course_name=course_name,
is_passed=is_passed,
status_description=status_description,
credits=credits,
score=score,
exam_date=exam_date,
course_type=course_type,
parent_id=parent_id,
level=level
)
class PlanCompletionCategory(BaseModel):
"""培养方案分类完成情况"""
category_id: str = Field("", description="分类ID")
category_name: str = Field("", description="分类名称")
min_credits: float = Field(0.0, description="最低修读学分")
completed_credits: float = Field(0.0, description="通过学分")
total_courses: int = Field(0, description="已修课程门数")
passed_courses: int = Field(0, description="已及格课程门数")
failed_courses: int = Field(0, description="未及格课程门数")
missing_required_courses: int = Field(0, description="必修课缺修门数")
subcategories: List["PlanCompletionCategory"] = Field(default_factory=list, description="子分类")
courses: List[PlanCompletionCourse] = Field(default_factory=list, description="课程列表")
@classmethod
def from_ztree_node(cls, node: dict) -> "PlanCompletionCategory":
"""从 zTree 节点创建分类对象"""
name = node.get("name", "")
flag_id = node.get("flagId", "")
# 移除HTML标签获取纯文本
clean_name = re.sub(r'<[^>]*>', '', name)
clean_name = re.sub(r'&nbsp;', ' ', clean_name).strip()
# 解析分类统计信息
# 格式:通识通修(最低修读学分:68,通过学分:34.4,已修课程门数:26,已及格课程门数:26,未及格课程门数:0,必修课缺修门数:12)
stats_match = re.search(
r'([^(]+)\(最低修读学分:([0-9.]+),通过学分:([0-9.]+),已修课程门数:(\d+),已及格课程门数:(\d+),未及格课程门数:(\d+),必修课缺修门数:(\d+)\)',
clean_name
)
if stats_match:
category_name = stats_match.group(1)
min_credits = float(stats_match.group(2))
completed_credits = float(stats_match.group(3))
total_courses = int(stats_match.group(4))
passed_courses = int(stats_match.group(5))
failed_courses = int(stats_match.group(6))
missing_required_courses = int(stats_match.group(7))
else:
# 子分类可能没有完整的统计信息
category_name = clean_name
min_credits = 0.0
completed_credits = 0.0
total_courses = 0
passed_courses = 0
failed_courses = 0
missing_required_courses = 0
return cls(
category_id=flag_id,
category_name=category_name,
min_credits=min_credits,
completed_credits=completed_credits,
total_courses=total_courses,
passed_courses=passed_courses,
failed_courses=failed_courses,
missing_required_courses=missing_required_courses
)
class PlanCompletionInfo(BaseModel):
"""培养方案完成情况总信息"""
plan_name: str = Field("", description="培养方案名称")
major: str = Field("", description="专业名称")
grade: str = Field("", description="年级")
categories: List[PlanCompletionCategory] = Field(default_factory=list, description="分类列表")
total_categories: int = Field(0, description="总分类数")
total_courses: int = Field(0, description="总课程数")
passed_courses: int = Field(0, description="已通过课程数")
failed_courses: int = Field(0, description="未通过课程数")
unread_courses: int = Field(0, description="未修读课程数")
def calculate_statistics(self):
"""计算统计信息"""
total_courses = 0
passed_courses = 0
failed_courses = 0
unread_courses = 0
def count_courses(categories: List[PlanCompletionCategory]):
nonlocal total_courses, passed_courses, failed_courses, unread_courses
for category in categories:
for course in category.courses:
total_courses += 1
if course.is_passed:
passed_courses += 1
elif course.status_description == "未通过":
failed_courses += 1
else:
unread_courses += 1
# 递归处理子分类
count_courses(category.subcategories)
count_courses(self.categories)
self.total_categories = len(self.categories)
self.total_courses = total_courses
self.passed_courses = passed_courses
self.failed_courses = failed_courses
self.unread_courses = unread_courses
class PlanCompletionResponse(BaseModel):
"""培养方案完成情况响应"""
code: int = Field(0, description="响应码")
message: str = Field("success", description="响应消息")
data: Optional[PlanCompletionInfo] = Field(None, description="培养方案完成情况数据")
class ErrorPlanCompletionInfo(PlanCompletionInfo):
"""错误的培养方案完成情况信息"""
plan_name: str = Field("请求失败", description="培养方案名称")
major: str = Field("请求失败", description="专业名称")
grade: str = Field("", description="年级")
total_categories: int = Field(-1, description="总分类数")
total_courses: int = Field(-1, description="总课程数")
passed_courses: int = Field(-1, description="已通过课程数")
failed_courses: int = Field(-1, description="未通过课程数")
unread_courses: int = Field(-1, description="未修读课程数")
class ErrorPlanCompletionResponse(BaseModel):
"""错误的培养方案完成情况响应"""
code: int = Field(-1, description="响应码")
message: str = Field("请求失败,请稍后重试", description="响应消息")
data: Optional[ErrorPlanCompletionInfo] = Field(default=None, description="错误数据")

View File

@@ -0,0 +1,102 @@
from typing import Optional
from pydantic import BaseModel, Field
import re
from loguru import logger
class SemesterWeekInfo(BaseModel):
"""学期周数信息"""
academic_year: str = Field("", description="学年,如 2025-2026")
semester: str = Field("", description="学期,如 秋、春")
week_number: int = Field(0, description="当前周数")
is_end: bool = Field(False, description="是否为学期结束")
weekday: str = Field("", description="星期几")
raw_text: str = Field("", description="原始文本")
def calculate_statistics(self):
"""计算统计信息(如果需要的话)"""
pass
@classmethod
def from_calendar_text(cls, calendar_text: str) -> "SemesterWeekInfo":
"""从日历文本解析学期周数信息
Args:
calendar_text: 日历文本,例如 "2025-2026 秋 第1周 星期三"
Returns:
SemesterWeekInfo: 学期周数信息对象
"""
# 清理文本
clean_text = re.sub(r'\s+', ' ', calendar_text.strip())
# 初始化默认值
academic_year = ""
semester = ""
week_number = 0
is_end = False
weekday = ""
try:
# 解析学年2025-2026
year_match = re.search(r'(\d{4}-\d{4})', clean_text)
if year_match:
academic_year = year_match.group(1)
# 解析学期:秋、春
semester_match = re.search(r'(春|秋|夏)', clean_text)
if semester_match:
semester = semester_match.group(1)
# 解析周数第1周、第15周等
week_match = re.search(r'第(\d+)周', clean_text)
if week_match:
week_number = int(week_match.group(1))
# 解析星期:星期一、星期二等
weekday_match = re.search(r'星期([一二三四五六日天])', clean_text)
if weekday_match:
weekday = weekday_match.group(1)
# 判断是否为学期结束通常第16周以后或包含"结束"等关键词)
if week_number >= 16 or "结束" in clean_text or "考试" in clean_text:
is_end = True
except Exception as e:
logger.warning(f"解析学期周数信息时出错: {str(e)}")
return cls(
academic_year=academic_year,
semester=semester,
week_number=week_number,
is_end=is_end,
weekday=weekday,
raw_text=clean_text
)
class ErrorSemesterWeekInfo(SemesterWeekInfo):
"""错误的学期周数信息"""
academic_year: str = Field("解析失败", description="学年")
semester: str = Field("解析失败", description="学期")
week_number: int = Field(-1, description="当前周数")
is_end: bool = Field(False, description="是否为学期结束")
weekday: str = Field("解析失败", description="星期几")
class SemesterWeekResponse(BaseModel):
"""学期周数信息响应模型"""
code: int = Field(0, description="响应码")
message: str = Field("获取成功", description="响应消息")
data: Optional[SemesterWeekInfo] = Field(None, description="学期周数数据")
class ErrorSemesterWeekResponse(BaseModel):
"""错误的学期周数信息响应"""
code: int = Field(-1, description="响应码")
message: str = Field("请求失败,请稍后重试", description="响应消息")
data: Optional[ErrorSemesterWeekInfo] = Field(default=None, description="错误数据")

View File

@@ -19,6 +19,7 @@ dependencies = [
"aiofiles>=24.1.0",
"textual>=5.2.0",
"aioboto3>=15.0.0",
"granian[pname,uvloop]>=2.5.2",
]
requires-python = "==3.12.*"
readme = "README.md"

View File

@@ -31,10 +31,15 @@ from .evaluate import (
get_task_manager,
remove_task_manager,
)
from .plan_completion import router as plan_completion_router
from datetime import datetime
from loguru import logger
jwc_router = APIRouter(prefix="/api/v1/jwc")
# 包含培养方案完成情况路由
jwc_router.include_router(plan_completion_router)
invite_tokens = []

View File

@@ -0,0 +1,182 @@
from fastapi import APIRouter, Depends
from typing import Optional
from loguru import logger
from provider.aufe.jwc import JWCClient
from provider.aufe.jwc.depends import get_jwc_client
from provider.loveac.authme import AuthmeResponse
from provider.aufe.jwc.plan_completion_model import (
PlanCompletionInfo,
ErrorPlanCompletionInfo,
)
from provider.aufe.jwc.semester_week_model import (
SemesterWeekInfo,
ErrorSemesterWeekInfo,
)
from router.common_model import BaseResponse, ErrorResponse
router = APIRouter(prefix="/plan-completion", tags=["培养方案完成情况"])
class PlanCompletionInfoResponse(BaseResponse):
"""培养方案完成情况响应模型"""
data: Optional[PlanCompletionInfo] = None
@classmethod
def from_data(
cls,
data: PlanCompletionInfo,
success_message: str = "success",
error_message: str = "请求失败",
) -> "PlanCompletionInfoResponse":
"""根据数据创建响应"""
if isinstance(data, ErrorPlanCompletionInfo) or data.total_courses == -1:
return cls(code=-1, message=error_message, data=None)
return cls(code=0, message=success_message, data=data)
@router.post(
"/fetch_plan_completion_info",
summary="获取培养方案完成情况",
response_model=PlanCompletionInfoResponse | AuthmeResponse | ErrorResponse,
)
async def fetch_plan_completion_info(client: JWCClient = Depends(get_jwc_client)):
"""
获取培养方案完成情况信息
返回数据包括:
- 培养方案基本信息(名称、专业、年级)
- 各分类的完成情况(通识通修、专业课等)
- 每门课程的详细状态(已通过、未通过、未修读)
- 统计信息(总课程数、通过数等)
"""
try:
result = await client.fetch_plan_completion_info()
# 检查是否是AuthmeResponse认证错误
if isinstance(result, AuthmeResponse):
return result
# 使用新的错误检测机制
response = PlanCompletionInfoResponse.from_data(
data=result,
success_message="培养方案完成情况获取成功",
error_message="获取培养方案完成情况失败,网络请求多次重试后仍无法连接教务系统,请稍后重试或联系管理员",
)
return response
except Exception as e:
logger.error(f"获取培养方案完成情况异常: {str(e)}")
return ErrorResponse(message=f"获取培养方案完成情况时发生系统错误:{str(e)}", code=500)
@router.post(
"/fetch_plan_completion_statistics",
summary="获取培养方案完成统计",
response_model=BaseResponse | AuthmeResponse | ErrorResponse,
)
async def fetch_plan_completion_statistics(client: JWCClient = Depends(get_jwc_client)):
"""
获取培养方案完成情况统计信息
返回简化的统计数据,包括:
- 总分类数
- 总课程数
- 已通过课程数
- 未通过课程数
- 未修读课程数
- 完成率
"""
try:
result = await client.fetch_plan_completion_info()
# 检查是否是AuthmeResponse认证错误
if isinstance(result, AuthmeResponse):
return result
# 检查是否是错误结果
if isinstance(result, ErrorPlanCompletionInfo) or result.total_courses == -1:
return BaseResponse(
code=-1,
message="获取培养方案统计信息失败,网络请求多次重试后仍无法连接教务系统,请稍后重试或联系管理员",
data=None
)
# 构建统计数据
statistics = {
"plan_name": result.plan_name,
"major": result.major,
"grade": result.grade,
"total_categories": result.total_categories,
"total_courses": result.total_courses,
"passed_courses": result.passed_courses,
"failed_courses": result.failed_courses,
"unread_courses": result.unread_courses,
"completion_rate": round(
result.passed_courses / result.total_courses * 100, 2
) if result.total_courses > 0 else 0.0
}
return BaseResponse(
code=0,
message="培养方案统计信息获取成功",
data=statistics
)
except Exception as e:
logger.error(f"获取培养方案统计信息异常: {str(e)}")
return ErrorResponse(message=f"获取培养方案统计信息时发生系统错误:{str(e)}", code=500)
class SemesterWeekInfoResponse(BaseResponse):
"""学期周数信息响应模型"""
data: Optional[SemesterWeekInfo] = None
@classmethod
def from_data(
cls,
data: SemesterWeekInfo,
success_message: str = "success",
error_message: str = "请求失败",
) -> "SemesterWeekInfoResponse":
"""根据数据创建响应"""
if isinstance(data, ErrorSemesterWeekInfo) or data.week_number == -1:
return cls(code=-1, message=error_message, data=None)
return cls(code=0, message=success_message, data=data)
@router.post("/fetch_semester_week_info", response_model=SemesterWeekInfoResponse, summary="获取学期周数信息")
async def fetch_semester_week_info(
jwc_client: JWCClient = Depends(get_jwc_client)
) -> SemesterWeekInfoResponse:
"""
获取当前学期周数信息
需要认证,返回当前学期、周数、是否结束等信息
"""
try:
logger.info("开始获取学期周数信息")
result = await jwc_client.fetch_semester_week_info()
if isinstance(result, ErrorSemesterWeekInfo) or result.week_number == -1:
logger.warning("获取学期周数信息失败")
return SemesterWeekInfoResponse.from_data(
result,
error_message="获取学期周数信息失败,请稍后重试"
)
logger.info(f"学期周数信息获取成功: {result.academic_year} {result.semester}{result.week_number}")
return SemesterWeekInfoResponse.from_data(
result,
success_message="学期周数信息获取成功"
)
except Exception as e:
logger.error(f"获取学期周数信息异常: {str(e)}")
return SemesterWeekInfoResponse(
code=500,
message=f"获取学期周数信息时发生系统错误:{str(e)}",
data=None
)

View File

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

View File

@@ -1,369 +0,0 @@
from typing import Optional, Dict, Any, 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()