From 6b90c6d7bb4dfb09ce03faae6eac0ac46e6267f7 Mon Sep 17 00:00:00 2001 From: Sibuxiangx Date: Sun, 7 Sep 2025 16:46:31 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9A=E6=96=B0=E5=A2=9E=20=E5=9F=B9?= =?UTF-8?q?=E5=85=BB=E6=96=B9=E6=A1=88=20=E6=9F=A5=E8=AF=A2=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pdm.lock | 90 ++++- provider/aufe/jwc/__init__.py | 392 ++++++++++++++++++++- provider/aufe/jwc/plan_completion_model.py | 342 ++++++++++++++++++ provider/aufe/jwc/semester_week_model.py | 102 ++++++ pyproject.toml | 1 + router/jwc/__init__.py | 5 + router/jwc/plan_completion.py | 182 ++++++++++ utils/__init__.py | 6 - utils/s3_client.py | 369 ------------------- 9 files changed, 1111 insertions(+), 378 deletions(-) create mode 100644 provider/aufe/jwc/plan_completion_model.py create mode 100644 provider/aufe/jwc/semester_week_model.py create mode 100644 router/jwc/plan_completion.py delete mode 100644 utils/__init__.py delete mode 100644 utils/s3_client.py diff --git a/pdm.lock b/pdm.lock index 44ac1e4..585af72 100644 --- a/pdm.lock +++ b/pdm.lock @@ -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" diff --git a/provider/aufe/jwc/__init__.py b/provider/aufe/jwc/__init__.py index 21b5e16..493f0a6 100644 --- a/provider/aufe/jwc/__init__.py +++ b/provider/aufe/jwc/__init__.py @@ -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() diff --git a/provider/aufe/jwc/plan_completion_model.py b/provider/aufe/jwc/plan_completion_model.py new file mode 100644 index 0000000..da310f7 --- /dev/null +++ b/provider/aufe/jwc/plan_completion_model.py @@ -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' ', ' ', 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' ', ' ', 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="错误数据") diff --git a/provider/aufe/jwc/semester_week_model.py b/provider/aufe/jwc/semester_week_model.py new file mode 100644 index 0000000..f05b319 --- /dev/null +++ b/provider/aufe/jwc/semester_week_model.py @@ -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="错误数据") diff --git a/pyproject.toml b/pyproject.toml index c2b86e6..9dadb5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/router/jwc/__init__.py b/router/jwc/__init__.py index 9239feb..70be01e 100644 --- a/router/jwc/__init__.py +++ b/router/jwc/__init__.py @@ -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 = [] diff --git a/router/jwc/plan_completion.py b/router/jwc/plan_completion.py new file mode 100644 index 0000000..7aa2ce9 --- /dev/null +++ b/router/jwc/plan_completion.py @@ -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 + ) diff --git a/utils/__init__.py b/utils/__init__.py deleted file mode 100644 index e44d8cb..0000000 --- a/utils/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -try: - from .s3_client import s3_client - __all__ = ["s3_client"] -except ImportError: - # 如果S3客户端依赖不可用,则不导出 - __all__ = [] \ No newline at end of file diff --git a/utils/s3_client.py b/utils/s3_client.py deleted file mode 100644 index 240314a..0000000 --- a/utils/s3_client.py +++ /dev/null @@ -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() \ No newline at end of file