⚒️ 重大重构 LoveACE V2
引入了 mongodb 对数据库进行了一定程度的数据加密 性能改善 代码简化 统一错误模型和响应 使用 apifox 作为文档
This commit is contained in:
18
loveace/router/endpoint/jwc/__init__.py
Normal file
18
loveace/router/endpoint/jwc/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from loveace.router.endpoint.jwc.academic import jwc_academic_router
|
||||
from loveace.router.endpoint.jwc.competition import jwc_competition_router
|
||||
from loveace.router.endpoint.jwc.exam import jwc_exam_router
|
||||
from loveace.router.endpoint.jwc.plan import jwc_plan_router
|
||||
from loveace.router.endpoint.jwc.schedule import jwc_schedules_router
|
||||
from loveace.router.endpoint.jwc.score import jwc_score_router
|
||||
from loveace.router.endpoint.jwc.term import jwc_term_router
|
||||
|
||||
jwc_base_router = APIRouter(prefix="/jwc", tags=["教务处"])
|
||||
jwc_base_router.include_router(jwc_exam_router)
|
||||
jwc_base_router.include_router(jwc_academic_router)
|
||||
jwc_base_router.include_router(jwc_term_router)
|
||||
jwc_base_router.include_router(jwc_score_router)
|
||||
jwc_base_router.include_router(jwc_plan_router)
|
||||
jwc_base_router.include_router(jwc_schedules_router)
|
||||
jwc_base_router.include_router(jwc_competition_router)
|
||||
245
loveace/router/endpoint/jwc/academic.py
Normal file
245
loveace/router/endpoint/jwc/academic.py
Normal file
@@ -0,0 +1,245 @@
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from loveace.router.endpoint.jwc.model.academic import (
|
||||
AcademicInfo,
|
||||
AcademicInfoTransformer,
|
||||
CourseSelectionStatus,
|
||||
CourseSelectionStatusTransformer,
|
||||
TrainingPlanInfo,
|
||||
TrainingPlanInfoTransformer,
|
||||
)
|
||||
from loveace.router.endpoint.jwc.model.base import JWCConfig
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
jwc_academic_router = APIRouter(
|
||||
prefix="/academic",
|
||||
responses=ProtectRouterErrorToCode.gen_code_table(),
|
||||
)
|
||||
|
||||
|
||||
ENDPOINTS = {
|
||||
"academic_info": "/main/academicInfo?sf_request_type=ajax",
|
||||
"training_plan": "/main/showPyfaInfo?sf_request_type=ajax",
|
||||
"course_selection_status": "/main/checkSelectCourseStatus?sf_request_type=ajax",
|
||||
}
|
||||
|
||||
|
||||
@jwc_academic_router.get(
|
||||
"/info", response_model=UniResponseModel[AcademicInfo], summary="获取学业信息"
|
||||
)
|
||||
async def get_academic_info(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[AcademicInfo] | JSONResponse:
|
||||
"""
|
||||
获取用户的学业信息(GPA、学分等)
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取当前学期学业情况
|
||||
- 获取平均学分绩点(GPA)
|
||||
- 实时从教务系统查询
|
||||
|
||||
💡 使用场景:
|
||||
- 个人中心查看学业成绩概览
|
||||
- 了解学业进展情况
|
||||
- 毕业时验证学业要求
|
||||
|
||||
Returns:
|
||||
AcademicInfo: 包含 GPA、学分、学业状态等信息
|
||||
"""
|
||||
try:
|
||||
conn.logger.info(f"获取用户 {conn.userid} 的学业信息")
|
||||
academic_info = await conn.client.post(
|
||||
JWCConfig().to_full_url(ENDPOINTS["academic_info"]),
|
||||
data={"flag": ""},
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
if not academic_info.status_code == 200:
|
||||
conn.logger.error(
|
||||
f"获取用户 {conn.userid} 的学业信息失败,状态码: {academic_info.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
try:
|
||||
data = academic_info.json()
|
||||
# 数组格式特殊处理
|
||||
data_to_validate = data[0]
|
||||
result = AcademicInfoTransformer.model_validate(
|
||||
data_to_validate
|
||||
).to_academic_info()
|
||||
return UniResponseModel[AcademicInfo](
|
||||
success=True,
|
||||
data=result,
|
||||
message="获取学业信息成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error("数据验证失败")
|
||||
conn.logger.debug(f"数据验证失败详情: {ve}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
|
||||
@jwc_academic_router.get(
|
||||
"/training_plan",
|
||||
response_model=UniResponseModel[TrainingPlanInfo],
|
||||
summary="获取培养方案信息",
|
||||
)
|
||||
async def get_training_plan_info(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[TrainingPlanInfo] | JSONResponse:
|
||||
"""
|
||||
获取用户的培养方案信息
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取所属专业的培养方案
|
||||
- 获取年级和专业名称
|
||||
- 提取关键信息(年级、专业)
|
||||
|
||||
💡 使用场景:
|
||||
- 了解培养方案要求
|
||||
- 查看所属年级和专业
|
||||
- 课程规划参考
|
||||
|
||||
Returns:
|
||||
TrainingPlanInfo: 包含方案名称、专业名称、年级信息
|
||||
"""
|
||||
try:
|
||||
conn.logger.info(f"获取用户 {conn.userid} 的培养方案信息")
|
||||
training_plan_info = await conn.client.get(
|
||||
JWCConfig().to_full_url(ENDPOINTS["training_plan"]),
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
if not training_plan_info.status_code == 200:
|
||||
conn.logger.error(
|
||||
f"获取用户 {conn.userid} 的培养方案信息失败,状态码: {training_plan_info.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
try:
|
||||
data = training_plan_info.json()
|
||||
transformer = TrainingPlanInfoTransformer.model_validate(data)
|
||||
if transformer.count > 0 and len(transformer.data) > 0:
|
||||
first_plan = transformer.data[0]
|
||||
if len(first_plan) >= 2:
|
||||
plan_name = first_plan[0]
|
||||
# 提取年级信息 - 假设格式为"20XX级..."
|
||||
grade_match = re.search(r"(\d{4})级", plan_name)
|
||||
grade = grade_match.group(1) if grade_match else ""
|
||||
|
||||
# 提取专业名称 - 假设格式为"20XX级XXX本科培养方案"
|
||||
major_match = re.search(r"\d{4}级(.+?)本科", plan_name)
|
||||
major_name = major_match.group(1) if major_match else ""
|
||||
result = TrainingPlanInfo(
|
||||
plan_name=plan_name, major_name=major_name, grade=grade
|
||||
)
|
||||
return UniResponseModel[TrainingPlanInfo](
|
||||
success=True,
|
||||
data=result,
|
||||
message="获取培养方案信息成功",
|
||||
error=None,
|
||||
)
|
||||
else:
|
||||
conn.logger.error("培养方案数据格式不正确,字段数量不足")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
else:
|
||||
conn.logger.error("培养方案数据为空")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error("数据验证失败")
|
||||
conn.logger.debug(f"数据验证失败详情: {ve}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
|
||||
@jwc_academic_router.get(
|
||||
"/course_selection_status",
|
||||
response_model=UniResponseModel[CourseSelectionStatus],
|
||||
summary="获取选课状态信息",
|
||||
)
|
||||
async def get_course_selection_status(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[CourseSelectionStatus] | JSONResponse:
|
||||
"""
|
||||
获取用户的选课状态
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取当前选课时间窗口
|
||||
- 获取选课开放状态
|
||||
- 显示选课时间提醒
|
||||
|
||||
💡 使用场景:
|
||||
- 查看当前是否在选课时间内
|
||||
- 获取选课开始和结束时间
|
||||
- 选课前的状态检查
|
||||
|
||||
Returns:
|
||||
CourseSelectionStatus: 包含选课状态、开始时间、结束时间等
|
||||
"""
|
||||
try:
|
||||
conn.logger.info(f"获取用户 {conn.userid} 的选课状态信息")
|
||||
course_selection_status = await conn.client.get(
|
||||
JWCConfig().to_full_url(ENDPOINTS["course_selection_status"]),
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
if not course_selection_status.status_code == 200:
|
||||
conn.logger.error(
|
||||
f"获取用户 {conn.userid} 的选课状态信息失败,状态码: {course_selection_status.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
try:
|
||||
data = course_selection_status.json()
|
||||
result = CourseSelectionStatus(
|
||||
can_select=(
|
||||
True
|
||||
if CourseSelectionStatusTransformer.model_validate(data).status_code
|
||||
== "1"
|
||||
else False
|
||||
)
|
||||
)
|
||||
return UniResponseModel[CourseSelectionStatus](
|
||||
success=True,
|
||||
data=result,
|
||||
message="获取选课状态成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error("数据验证失败")
|
||||
conn.logger.debug(f"数据验证失败详情: {ve}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
121
loveace/router/endpoint/jwc/competition.py
Normal file
121
loveace/router/endpoint/jwc/competition.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from loveace.router.endpoint.jwc.model.competition import (
|
||||
CompetitionFullResponse,
|
||||
)
|
||||
from loveace.router.endpoint.jwc.utils.aspnet_form_parser import ASPNETFormParser
|
||||
from loveace.router.endpoint.jwc.utils.competition import CompetitionInfoParser
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
jwc_competition_router = APIRouter(
|
||||
prefix="/competition",
|
||||
responses=ProtectRouterErrorToCode().gen_code_table(),
|
||||
)
|
||||
|
||||
ENDPOINT = {
|
||||
"awards_page": "http://211-86-241-245.vpn2.aufe.edu.cn:8118/xsXmMain.aspx",
|
||||
}
|
||||
|
||||
|
||||
@jwc_competition_router.get(
|
||||
"/info",
|
||||
summary="获取完整学科竞赛信息",
|
||||
response_model=UniResponseModel[CompetitionFullResponse],
|
||||
)
|
||||
async def get_full_competition_info(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[CompetitionFullResponse] | JSONResponse:
|
||||
"""
|
||||
获取用户的完整学科竞赛信息(一次请求获取所有数据)
|
||||
|
||||
✅ 功能特性:
|
||||
- 一次请求获取获奖项目列表和学分汇总
|
||||
- 减少网络IO调用,提高性能
|
||||
- 返回完整的竞赛相关数据
|
||||
|
||||
📊 返回数据:
|
||||
- 获奖项目列表(包含项目信息、学分、奖励等)
|
||||
- 学分汇总(各类学分统计)
|
||||
- 学生基本信息
|
||||
|
||||
💡 使用场景:
|
||||
- 需要完整竞赛信息的仪表板
|
||||
- 移动端应用(减少请求次数)
|
||||
- 性能敏感的场景
|
||||
|
||||
Returns:
|
||||
CompetitionFullResponse: 包含完整竞赛信息的响应对象
|
||||
"""
|
||||
try:
|
||||
conn.logger.info(f"获取用户 {conn.userid} 的完整学科竞赛信息")
|
||||
|
||||
# 第一次访问页面获取 HTML 内容和 Cookie
|
||||
conn.logger.debug("第一次访问创新创业管理平台页面获取表单数据")
|
||||
index_response = await conn.client.get(
|
||||
ENDPOINT["awards_page"],
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
|
||||
if index_response.status_code != 200:
|
||||
conn.logger.error(f"第一次访问创新创业管理平台失败,状态码: {index_response.status_code}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
# 从第一次响应中提取动态表单数据
|
||||
conn.logger.debug("从页面中提取动态表单数据")
|
||||
try:
|
||||
form_data = ASPNETFormParser.get_awards_list_form_data(index_response.text)
|
||||
conn.logger.debug(f"成功提取表单数据,__VIEWSTATE 长度: {len(form_data.get('__VIEWSTATE', ''))}")
|
||||
except Exception as e:
|
||||
conn.logger.error(f"提取表单数据失败: {e}")
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
# 第二次请求:使用动态表单数据请求已申报奖项页面
|
||||
conn.logger.debug("使用动态表单数据请求已申报奖项页面")
|
||||
result_response = await conn.client.post(
|
||||
ENDPOINT["awards_page"],
|
||||
follow_redirects=True,
|
||||
data=form_data,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
|
||||
if result_response.status_code != 200:
|
||||
conn.logger.error(f"请求已申报奖项页面失败,状态码: {result_response.status_code}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
# 一次性解析所有数据
|
||||
parser = CompetitionInfoParser(result_response.text)
|
||||
full_response = parser.parse_full_competition_info()
|
||||
|
||||
conn.logger.info(
|
||||
f"成功获取用户 {conn.userid} 的完整竞赛信息,共 {full_response.total_awards_count} 项获奖"
|
||||
)
|
||||
|
||||
return UniResponseModel[CompetitionFullResponse](
|
||||
success=True,
|
||||
data=full_response,
|
||||
message="获取竞赛信息成功",
|
||||
error=None,
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
conn.logger.error(f"用户 {conn.userid} 的竞赛信息数据验证失败: {e}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"用户 {conn.userid} 的完整竞赛信息获取失败: {e}")
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
97
loveace/router/endpoint/jwc/exam.py
Normal file
97
loveace/router/endpoint/jwc/exam.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from loveace.router.endpoint.jwc.academic import get_academic_info
|
||||
from loveace.router.endpoint.jwc.model.academic import AcademicInfo
|
||||
from loveace.router.endpoint.jwc.model.exam import ExamInfoResponse
|
||||
from loveace.router.endpoint.jwc.utils.exam import fetch_unified_exam_info
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
jwc_exam_router = APIRouter(
|
||||
prefix="/exam",
|
||||
responses=ProtectRouterErrorToCode().gen_code_table(),
|
||||
)
|
||||
|
||||
|
||||
@jwc_exam_router.get(
|
||||
"/info", response_model=UniResponseModel[ExamInfoResponse], summary="获取考试信息"
|
||||
)
|
||||
async def get_exam_info(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[ExamInfoResponse] | JSONResponse:
|
||||
"""
|
||||
获取用户的考试信息
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取当前学期的考试安排
|
||||
- 自动确定考试时间范围
|
||||
- 显示考试时间、地点、课程等信息
|
||||
|
||||
💡 使用场景:
|
||||
- 查看即将进行的考试
|
||||
- 了解考试安排和地点
|
||||
- 提前规划复习计划
|
||||
|
||||
Returns:
|
||||
ExamInfoResponse: 包含考试列表和总数
|
||||
"""
|
||||
try:
|
||||
academic_info = await get_academic_info(conn)
|
||||
if isinstance(academic_info, UniResponseModel):
|
||||
if academic_info.data and isinstance(academic_info.data, AcademicInfo):
|
||||
term_code = academic_info.data.current_term
|
||||
else:
|
||||
result = ExamInfoResponse(exams=[], total_count=0)
|
||||
return UniResponseModel[ExamInfoResponse](
|
||||
success=False,
|
||||
data=result,
|
||||
message="无法获取学期信息",
|
||||
error=None,
|
||||
)
|
||||
elif isinstance(academic_info, AcademicInfo):
|
||||
term_code = academic_info.current_term
|
||||
else:
|
||||
result = ExamInfoResponse(exams=[], total_count=0)
|
||||
return UniResponseModel[ExamInfoResponse](
|
||||
success=False,
|
||||
data=result,
|
||||
message="无法获取学期信息",
|
||||
error=None,
|
||||
)
|
||||
conn.logger.info(f"获取用户 {conn.userid} 的考试信息")
|
||||
|
||||
start_date = datetime.now()
|
||||
# termcode 结尾为 1 为秋季学期,考试应在3月之前,2为春季学期,考试应在9月之前
|
||||
end_date = datetime(
|
||||
year=start_date.year + (1 if term_code.endswith("1") else 0),
|
||||
month=3 if term_code.endswith("1") else 9,
|
||||
day=30,
|
||||
)
|
||||
exam_info = await fetch_unified_exam_info(
|
||||
conn,
|
||||
start_date=start_date.strftime("%Y-%m-%d"),
|
||||
end_date=end_date.strftime("%Y-%m-%d"),
|
||||
term_code=term_code,
|
||||
)
|
||||
return UniResponseModel[ExamInfoResponse](
|
||||
success=True,
|
||||
data=exam_info,
|
||||
message="获取考试信息成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as e:
|
||||
conn.logger.error(f"用户 {conn.userid} 的考试信息数据验证失败: {e}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"用户 {conn.userid} 的考试信息获取失败: {e}")
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
67
loveace/router/endpoint/jwc/model/academic.py
Normal file
67
loveace/router/endpoint/jwc/model/academic.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from loveace.router.endpoint.jwc.utils.zxjxjhh_to_term_format import (
|
||||
convert_zxjxjhh_to_term_format,
|
||||
)
|
||||
|
||||
|
||||
class AcademicInfoTransformer(BaseModel):
|
||||
"""学术信息数据项"""
|
||||
|
||||
completed_courses: int = Field(0, alias="courseNum")
|
||||
failed_courses: int = Field(0, alias="coursePas")
|
||||
gpa: float = Field(0, alias="gpa")
|
||||
current_term: str = Field("", alias="zxjxjhh")
|
||||
pending_courses: int = Field(0, alias="courseNum_bxqyxd")
|
||||
|
||||
def to_academic_info(self) -> "AcademicInfo":
|
||||
"""转换为 AcademicInfo"""
|
||||
return AcademicInfo(
|
||||
completed_courses=self.completed_courses,
|
||||
failed_courses=self.failed_courses,
|
||||
pending_courses=self.pending_courses,
|
||||
gpa=self.gpa,
|
||||
current_term=self.current_term,
|
||||
current_term_name=convert_zxjxjhh_to_term_format(self.current_term),
|
||||
)
|
||||
|
||||
|
||||
class AcademicInfo(BaseModel):
|
||||
"""学术信息数据模型"""
|
||||
|
||||
completed_courses: int = Field(0, description="已修课程数")
|
||||
failed_courses: int = Field(0, description="不及格课程数")
|
||||
pending_courses: int = Field(0, description="本学期待修课程数")
|
||||
gpa: float = Field(0, description="绩点")
|
||||
current_term: str = Field("", description="当前学期")
|
||||
current_term_name: str = Field("", description="当前学期名称")
|
||||
|
||||
|
||||
class TrainingPlanInfoTransformer(BaseModel):
|
||||
"""培养方案响应模型"""
|
||||
|
||||
count: int = 0
|
||||
data: List[List[str]] = []
|
||||
|
||||
|
||||
class TrainingPlanInfo(BaseModel):
|
||||
"""培养方案信息模型"""
|
||||
|
||||
plan_name: str = Field("", description="培养方案名称")
|
||||
major_name: str = Field("", description="专业名称")
|
||||
grade: str = Field("", description="年级")
|
||||
|
||||
|
||||
class CourseSelectionStatusTransformer(BaseModel):
|
||||
"""选课状态响应模型新格式"""
|
||||
|
||||
term_name: str = Field("", alias="zxjxjhm")
|
||||
status_code: str = Field("", alias="retString")
|
||||
|
||||
|
||||
class CourseSelectionStatus(BaseModel):
|
||||
"""选课状态信息"""
|
||||
|
||||
can_select: bool = Field(False, description="是否可以选课")
|
||||
10
loveace/router/endpoint/jwc/model/base.py
Normal file
10
loveace/router/endpoint/jwc/model/base.py
Normal file
@@ -0,0 +1,10 @@
|
||||
class JWCConfig:
|
||||
"""教务系统配置常量"""
|
||||
|
||||
DEFAULT_BASE_URL = "http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/"
|
||||
|
||||
def to_full_url(self, path: str) -> str:
|
||||
"""将路径转换为完整URL"""
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
return path
|
||||
return self.DEFAULT_BASE_URL.rstrip("/") + "/" + path.lstrip("/")
|
||||
84
loveace/router/endpoint/jwc/model/competition.py
Normal file
84
loveace/router/endpoint/jwc/model/competition.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AwardProject(BaseModel):
|
||||
"""
|
||||
获奖项目信息模型
|
||||
|
||||
表示用户通过创新创业管理平台申报的单个获奖项目
|
||||
"""
|
||||
|
||||
project_id: str = Field("", description="申报ID,唯一标识符")
|
||||
project_name: str = Field("", description="项目名称/赛事名称")
|
||||
level: str = Field("", description="级别(校级/省部级/国家级等)")
|
||||
grade: str = Field("", description="等级/奖项等级(一等奖/二等奖等)")
|
||||
award_date: str = Field("", description="获奖日期,格式为 YYYY/M/D")
|
||||
applicant_id: str = Field("", description="主持人姓名")
|
||||
applicant_name: str = Field("", description="参与人姓名(作为用户)")
|
||||
order: int = Field(0, description="顺序号(多人项目的排序)")
|
||||
credits: float = Field(0.0, description="获奖学分")
|
||||
bonus: float = Field(0.0, description="奖励金额")
|
||||
status: str = Field("", description="申报状态(提交/审核中/已审核等)")
|
||||
verification_status: str = Field(
|
||||
"", description="学校审核状态(通过/未通过/待审核等)"
|
||||
)
|
||||
|
||||
|
||||
class CreditsSummary(BaseModel):
|
||||
"""
|
||||
学分汇总信息模型
|
||||
|
||||
存储用户在创新创业管理平台的各类学分统计
|
||||
"""
|
||||
|
||||
discipline_competition_credits: Optional[float] = Field(
|
||||
None, description="学科竞赛学分"
|
||||
)
|
||||
scientific_research_credits: Optional[float] = Field(
|
||||
None, description="科研项目学分"
|
||||
)
|
||||
transferable_competition_credits: Optional[float] = Field(
|
||||
None, description="可转竞赛类学分"
|
||||
)
|
||||
innovation_practice_credits: Optional[float] = Field(
|
||||
None, description="创新创业实践学分"
|
||||
)
|
||||
ability_certification_credits: Optional[float] = Field(
|
||||
None, description="能力资格认证学分"
|
||||
)
|
||||
other_project_credits: Optional[float] = Field(None, description="其他项目学分")
|
||||
|
||||
|
||||
class CompetitionAwardsResponse(BaseModel):
|
||||
"""
|
||||
获奖项目列表响应模型
|
||||
"""
|
||||
|
||||
student_id: str = Field("", description="学生ID/工号")
|
||||
total_count: int = Field(0, description="获奖项目总数")
|
||||
awards: List[AwardProject] = Field(default_factory=list, description="获奖项目列表")
|
||||
|
||||
|
||||
class CompetitionCreditsSummaryResponse(BaseModel):
|
||||
"""
|
||||
学分汇总响应模型
|
||||
"""
|
||||
|
||||
student_id: str = Field("", description="学生ID/工号")
|
||||
credits_summary: Optional[CreditsSummary] = Field(None, description="学分汇总详情")
|
||||
|
||||
|
||||
class CompetitionFullResponse(BaseModel):
|
||||
"""
|
||||
学科竞赛完整信息响应模型
|
||||
|
||||
整合了获奖项目列表和学分汇总信息,减少网络IO调用
|
||||
在单次请求中返回所有竞赛相关数据
|
||||
"""
|
||||
|
||||
student_id: str = Field("", description="学生ID/工号")
|
||||
total_awards_count: int = Field(0, description="获奖项目总数")
|
||||
awards: List[AwardProject] = Field(default_factory=list, description="获奖项目列表")
|
||||
credits_summary: Optional[CreditsSummary] = Field(None, description="学分汇总详情")
|
||||
65
loveace/router/endpoint/jwc/model/exam.py
Normal file
65
loveace/router/endpoint/jwc/model/exam.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ExamScheduleItem(BaseModel):
|
||||
"""考试安排项目 - 校统考格式"""
|
||||
|
||||
title: str = "" # 考试标题,包含课程名、时间、地点等信息
|
||||
start: str = "" # 考试日期 (YYYY-MM-DD)
|
||||
color: str = "" # 显示颜色
|
||||
|
||||
|
||||
class OtherExamRecord(BaseModel):
|
||||
"""其他考试记录"""
|
||||
|
||||
term_code: str = Field("", alias="ZXJXJHH") # 学期代码
|
||||
term_name: str = Field("", alias="ZXJXJHM") # 学期名称
|
||||
exam_name: str = Field("", alias="KSMC") # 考试名称
|
||||
course_code: str = Field("", alias="KCH") # 课程代码
|
||||
course_name: str = Field("", alias="KCM") # 课程名称
|
||||
class_number: str = Field("", alias="KXH") # 课序号
|
||||
student_id: str = Field("", alias="XH") # 学号
|
||||
student_name: str = Field("", alias="XM") # 姓名
|
||||
exam_location: str = Field("", alias="KSDD") # 考试地点
|
||||
exam_date: str = Field("", alias="KSRQ") # 考试日期
|
||||
exam_time: str = Field("", alias="KSSJ") # 考试时间
|
||||
note: str = Field("", alias="BZ") # 备注
|
||||
row_number: str = Field("", alias="RN") # 行号
|
||||
|
||||
|
||||
class OtherExamResponse(BaseModel):
|
||||
"""其他考试查询响应"""
|
||||
|
||||
page_size: int = Field(0, alias="pageSize")
|
||||
page_num: int = Field(0, alias="pageNum")
|
||||
page_context: Dict[str, int] = Field(default_factory=dict, alias="pageContext")
|
||||
records: Optional[List[OtherExamRecord]] = Field(alias="records")
|
||||
|
||||
|
||||
class UnifiedExamInfo(BaseModel):
|
||||
"""统一考试信息模型 - 对外提供的统一格式"""
|
||||
|
||||
course_name: str = Field("", description="课程名称")
|
||||
exam_date: str = Field("", description="考试日期")
|
||||
exam_time: str = Field("", description="考试时间")
|
||||
exam_location: str = Field("", description="考试地点")
|
||||
exam_type: str = Field("", description="考试类型")
|
||||
note: str = Field("", description="备注")
|
||||
|
||||
|
||||
class ExamInfoResponse(BaseModel):
|
||||
"""考试信息统一响应模型"""
|
||||
|
||||
exams: List[UnifiedExamInfo] = Field(
|
||||
default_factory=list, description="考试信息列表"
|
||||
)
|
||||
total_count: int = Field(0, description="考试总数")
|
||||
|
||||
|
||||
class SeatInfo(BaseModel):
|
||||
"""座位信息模型"""
|
||||
|
||||
course_name: str = Field("", description="课程名称")
|
||||
seat_number: str = Field("", description="座位号")
|
||||
348
loveace/router/endpoint/jwc/model/plan.py
Normal file
348
loveace/router/endpoint/jwc/model/plan.py
Normal file
@@ -0,0 +1,348 @@
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PlanCompletionCourse(BaseModel):
|
||||
"""培养方案课程完成情况"""
|
||||
|
||||
flag_id: str = Field("", description="课程标识ID")
|
||||
flag_type: str = Field("", description="节点类型:001=分类, 002=子分类, kch=课程")
|
||||
course_code: str = Field("", description="课程代码,如 PDA2121005")
|
||||
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
|
||||
49
loveace/router/endpoint/jwc/model/schedule.py
Normal file
49
loveace/router/endpoint/jwc/model/schedule.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
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="课程列表")
|
||||
28
loveace/router/endpoint/jwc/model/score.py
Normal file
28
loveace/router/endpoint/jwc/model/score.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ScoreRecord(BaseModel):
|
||||
"""成绩记录模型"""
|
||||
|
||||
sequence: int = Field(0, description="序号")
|
||||
term_id: str = Field("", description="学期ID")
|
||||
course_code: str = Field("", description="课程代码")
|
||||
course_class: str = Field("", description="课程班级")
|
||||
course_name_cn: str = Field("", description="课程名称(中文)")
|
||||
course_name_en: str = Field("", description="课程名称(英文)")
|
||||
credits: str = Field("", description="学分")
|
||||
hours: int = Field(0, description="学时")
|
||||
course_type: Optional[str] = Field(None, description="课程性质")
|
||||
exam_type: Optional[str] = Field(None, description="考试性质")
|
||||
score: str = Field("", description="成绩")
|
||||
retake_score: Optional[str] = Field(None, description="重修成绩")
|
||||
makeup_score: Optional[str] = Field(None, description="补考成绩")
|
||||
|
||||
|
||||
class TermScoreResponse(BaseModel):
|
||||
"""学期成绩响应模型"""
|
||||
|
||||
total_count: int = Field(0, description="总记录数")
|
||||
records: List[ScoreRecord] = Field(default_factory=list, description="成绩记录列表")
|
||||
20
loveace/router/endpoint/jwc/model/term.py
Normal file
20
loveace/router/endpoint/jwc/model/term.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TermItem(BaseModel):
|
||||
"""学期信息项"""
|
||||
|
||||
term_code: str = Field(..., description="学期代码")
|
||||
term_name: str = Field(..., description="学期名称")
|
||||
is_current: bool = Field(..., description="是否为当前学期")
|
||||
|
||||
|
||||
class CurrentTermInfo(BaseModel):
|
||||
"""学期周数信息"""
|
||||
|
||||
academic_year: str = Field("", description="学年,如 2025-2026")
|
||||
current_term_name: str = Field("", description="学期,如 秋、春")
|
||||
week_number: int = Field(0, description="当前周数")
|
||||
start_at: str = Field("", description="学期开始时间,格式 YYYY-MM-DD")
|
||||
is_end: bool = Field(False, description="是否为学期结束")
|
||||
weekday: int = Field(0, description="星期几")
|
||||
262
loveace/router/endpoint/jwc/plan.py
Normal file
262
loveace/router/endpoint/jwc/plan.py
Normal file
@@ -0,0 +1,262 @@
|
||||
import re
|
||||
|
||||
import ujson
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from httpx import HTTPError
|
||||
from pydantic import ValidationError
|
||||
from ujson import JSONDecodeError
|
||||
|
||||
from loveace.router.endpoint.jwc.model.base import JWCConfig
|
||||
from loveace.router.endpoint.jwc.model.plan import (
|
||||
PlanCompletionCategory,
|
||||
PlanCompletionInfo,
|
||||
)
|
||||
from loveace.router.endpoint.jwc.utils.plan import populate_category_children
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
ENDPOINT = {
|
||||
"plan": "/student/integratedQuery/planCompletion/index",
|
||||
}
|
||||
|
||||
jwc_plan_router = APIRouter(
|
||||
prefix="/plan",
|
||||
responses=ProtectRouterErrorToCode().gen_code_table(),
|
||||
)
|
||||
|
||||
|
||||
@jwc_plan_router.get(
|
||||
"/current",
|
||||
summary="获取当前培养方案完成信息",
|
||||
response_model=UniResponseModel[PlanCompletionInfo],
|
||||
)
|
||||
async def get_current_plan_completion(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[PlanCompletionInfo] | JSONResponse:
|
||||
"""
|
||||
获取用户的培养方案完成情况
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取培养方案的总体完成进度
|
||||
- 按类别显示各类课程的完成情况
|
||||
- 显示已完成、未完成、可选课程等
|
||||
|
||||
💡 使用场景:
|
||||
- 查看毕业要求的完成进度
|
||||
- 了解还需要修读哪些课程
|
||||
- 规划后续选课
|
||||
|
||||
Returns:
|
||||
PlanCompletionInfo: 包含方案完成情况和各类别详情
|
||||
"""
|
||||
try:
|
||||
conn.logger.info("获取当前培养方案完成信息")
|
||||
response = await conn.client.get(
|
||||
JWCConfig().to_full_url(ENDPOINT["plan"]),
|
||||
follow_redirects=True,
|
||||
timeout=600,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(f"获取培养方案信息失败,状态码: {response.status_code}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
html_content = response.text
|
||||
|
||||
# 使用BeautifulSoup解析HTML
|
||||
soup = BeautifulSoup(html_content, "lxml")
|
||||
|
||||
# 提取培养方案名称
|
||||
plan_name = ""
|
||||
|
||||
# 查找包含"培养方案"的h4标签
|
||||
h4_elements = soup.find_all("h4")
|
||||
for h4 in h4_elements:
|
||||
text = h4.get_text(strip=True) if h4 else ""
|
||||
if "培养方案" in text:
|
||||
plan_name = text
|
||||
conn.logger.info(f"找到培养方案标题: {plan_name}")
|
||||
break
|
||||
|
||||
# 解析专业和年级信息
|
||||
major = ""
|
||||
grade = ""
|
||||
if plan_name:
|
||||
grade_match = re.search(r"(\d{4})级", plan_name)
|
||||
if grade_match:
|
||||
grade = grade_match.group(1)
|
||||
|
||||
major_match = re.search(r"\d{4}级(.+?)本科", plan_name)
|
||||
if major_match:
|
||||
major = major_match.group(1)
|
||||
|
||||
# 查找zTree数据
|
||||
ztree_data = []
|
||||
|
||||
# 在script标签中查找zTree初始化数据
|
||||
scripts = soup.find_all("script")
|
||||
for script in scripts:
|
||||
try:
|
||||
script_text = script.get_text() if script else ""
|
||||
if "$.fn.zTree.init" in script_text and "flagId" in script_text:
|
||||
conn.logger.info("找到包含zTree初始化的script标签")
|
||||
|
||||
# 提取zTree数据
|
||||
# 尝试多种模式匹配
|
||||
patterns = [
|
||||
r'\$\.fn\.zTree\.init\(\$\("#treeDemo"\),\s*setting,\s*(\[.*?\])\s*\);',
|
||||
r"\.zTree\.init\([^,]+,\s*[^,]+,\s*(\[.*?\])\s*\);",
|
||||
r'init\(\$\("#treeDemo"\)[^,]*,\s*[^,]*,\s*(\[.*?\])',
|
||||
]
|
||||
|
||||
json_part = None
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, script_text, re.DOTALL)
|
||||
if match:
|
||||
json_part = match.group(1)
|
||||
conn.logger.info(
|
||||
f"使用模式匹配成功提取zTree数据: {len(json_part)}字符"
|
||||
)
|
||||
break
|
||||
|
||||
if json_part:
|
||||
# 清理和修复JSON格式
|
||||
# 移除JavaScript注释和多余的逗号
|
||||
json_part = re.sub(r"//.*?\n", "\n", json_part)
|
||||
json_part = re.sub(r"/\*.*?\*/", "", json_part, flags=re.DOTALL)
|
||||
json_part = re.sub(r",\s*}", "}", json_part)
|
||||
json_part = re.sub(r",\s*]", "]", json_part)
|
||||
|
||||
try:
|
||||
ztree_data = ujson.loads(json_part)
|
||||
conn.logger.info(f"JSON解析成功,共{len(ztree_data)}个节点")
|
||||
break
|
||||
except JSONDecodeError as e:
|
||||
conn.logger.warning(f"JSON解析失败: {str(e)}")
|
||||
# 如果JSON解析失败,不使用手动解析,直接跳过
|
||||
continue
|
||||
else:
|
||||
conn.logger.warning("未能通过模式匹配提取zTree数据")
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
if not ztree_data:
|
||||
conn.logger.warning("未找到有效的zTree数据")
|
||||
|
||||
# 输出调试信息
|
||||
conn.logger.info(f"HTML内容长度: {len(html_content)}")
|
||||
conn.logger.info(f"找到的script标签数量: {len(soup.find_all('script'))}")
|
||||
|
||||
# 检查是否包含关键词
|
||||
contains_ztree = "zTree" in html_content
|
||||
contains_flagid = "flagId" in html_content
|
||||
contains_plan = "培养方案" in html_content
|
||||
conn.logger.info(
|
||||
f"HTML包含关键词: zTree={contains_ztree}, flagId={contains_flagid}, 培养方案={contains_plan}"
|
||||
)
|
||||
conn.logger.warning("未找到有效的zTree数据")
|
||||
|
||||
if contains_plan:
|
||||
conn.logger.warning(
|
||||
"检测到培养方案内容,但zTree数据解析失败,可能页面结构已变化"
|
||||
)
|
||||
else:
|
||||
conn.logger.warning(
|
||||
"未检测到培养方案相关内容,可能需要重新登录或检查访问权限"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id,
|
||||
message="未找到有效的培养方案数据,请检查登录状态或稍后再试",
|
||||
)
|
||||
# 解析zTree数据构建分类和课程信息
|
||||
try:
|
||||
# 按层级组织数据
|
||||
nodes_by_id = {node["id"]: node for node in ztree_data}
|
||||
root_categories = []
|
||||
|
||||
# 统计根分类和所有节点信息,用于调试
|
||||
all_parent_ids = set()
|
||||
root_nodes = []
|
||||
|
||||
for node in ztree_data:
|
||||
parent_id = node.get("pId", "")
|
||||
all_parent_ids.add(parent_id)
|
||||
|
||||
# 根分类的判断条件:pId为"-1"(这是zTree中真正的根节点标识)
|
||||
# 从HTML示例可以看出,真正的根分类的pId是"-1"
|
||||
is_root_category = parent_id == "-1"
|
||||
|
||||
if is_root_category:
|
||||
root_nodes.append(node)
|
||||
|
||||
conn.logger.info(
|
||||
f"zTree数据分析: 总节点数={len(ztree_data)}, 根节点数={len(root_nodes)}, 不同父ID数={len(all_parent_ids)}"
|
||||
)
|
||||
conn.logger.debug(f"所有父ID: {sorted(all_parent_ids)}")
|
||||
|
||||
# 构建分类树
|
||||
for node in root_nodes:
|
||||
category = PlanCompletionCategory.from_ztree_node(node)
|
||||
# 填充分类的子分类和课程(支持多层嵌套)
|
||||
try:
|
||||
populate_category_children(category, node["id"], nodes_by_id, conn)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"填充分类子项异常: {str(e)}")
|
||||
conn.logger.error(
|
||||
f"异常节点信息: category_id={node['id']}, 错误详情: {str(e)}"
|
||||
)
|
||||
root_categories.append(category)
|
||||
conn.logger.info(
|
||||
f"创建根分类: {category.category_name} (ID: {node['id']})"
|
||||
)
|
||||
|
||||
# 创建完成情况信息
|
||||
completion_info = PlanCompletionInfo(
|
||||
plan_name=plan_name,
|
||||
major=major,
|
||||
grade=grade,
|
||||
categories=root_categories,
|
||||
total_categories=0,
|
||||
total_courses=0,
|
||||
passed_courses=0,
|
||||
failed_courses=0,
|
||||
unread_courses=0,
|
||||
)
|
||||
|
||||
# 计算统计信息
|
||||
completion_info.calculate_statistics()
|
||||
conn.logger.info(
|
||||
f"培养方案完成信息统计: 分类数={completion_info.total_categories}, 课程数={completion_info.total_courses}, 已过课程={completion_info.passed_courses}, 未过课程={completion_info.failed_courses}, 未修读课程={completion_info.unread_courses}"
|
||||
)
|
||||
return UniResponseModel[PlanCompletionInfo](
|
||||
success=True,
|
||||
data=completion_info,
|
||||
message="获取培养方案完成信息成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"数据验证错误: {ve}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"HTTP请求错误: {he}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
246
loveace/router/endpoint/jwc/schedule.py
Normal file
246
loveace/router/endpoint/jwc/schedule.py
Normal file
@@ -0,0 +1,246 @@
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from loveace.router.endpoint.jwc.model.base import JWCConfig
|
||||
from loveace.router.endpoint.jwc.model.schedule import ScheduleData
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
jwc_schedules_router = APIRouter(
|
||||
prefix="/schedule",
|
||||
responses=ProtectRouterErrorToCode.gen_code_table(),
|
||||
)
|
||||
|
||||
|
||||
ENDPOINTS = {
|
||||
"student_schedule_pre": "/student/courseSelect/calendarSemesterCurriculum/index",
|
||||
"student_schedule": "/student/courseSelect/thisSemesterCurriculum/{dynamic_path}/ajaxStudentSchedule/past/callback",
|
||||
"section_and_time": "/ajax/getSectionAndTime",
|
||||
}
|
||||
|
||||
|
||||
@jwc_schedules_router.get(
|
||||
"/{term_code}/table",
|
||||
summary="获取课表信息",
|
||||
response_model=UniResponseModel[ScheduleData],
|
||||
)
|
||||
async def get_schedule_table(
|
||||
term_code: str, conn: AUFEConnection = Depends(get_aufe_conn)
|
||||
) -> UniResponseModel[ScheduleData] | JSONResponse:
|
||||
"""
|
||||
获取指定学期的课程表
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取指定学期的完整课程表
|
||||
- 显示课程名称、教室、时间、教师等信息
|
||||
- 支持按周查询
|
||||
|
||||
💡 使用场景:
|
||||
- 查看本周课程安排
|
||||
- 了解完整学期课程表
|
||||
- 课表分享和导出
|
||||
|
||||
Args:
|
||||
term_code: 学期代码(如:2023-2024-1)
|
||||
|
||||
Returns:
|
||||
ScheduleData: 包含课程表数据和课程详情
|
||||
"""
|
||||
try:
|
||||
conn.logger.info(f"开始获取学期 {term_code} 的课表信息")
|
||||
# 第一步:访问课表预备页面,获取动态路径
|
||||
|
||||
dynamic_page = JWCConfig().to_full_url(ENDPOINTS["student_schedule_pre"])
|
||||
dynamic_page_response = await conn.client.get(
|
||||
dynamic_page, follow_redirects=True, timeout=conn.timeout
|
||||
)
|
||||
if dynamic_page_response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取课表预备页面失败,状态码: {dynamic_page_response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode.remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
soup = BeautifulSoup(dynamic_page_response.text, "lxml")
|
||||
|
||||
# 尝试从页面中提取动态路径
|
||||
scripts = soup.find_all("script")
|
||||
dynamic_path = "B2RMNJkT95" # 默认值
|
||||
for script in scripts:
|
||||
try:
|
||||
script_text = script.string # type: ignore
|
||||
if script_text and "ajaxStudentSchedule" in script_text:
|
||||
# 使用正则表达式提取路径
|
||||
match = re.search(
|
||||
r"/([A-Za-z0-9]+)/ajaxStudentSchedule", script_text
|
||||
)
|
||||
if match:
|
||||
dynamic_path = match.group(1)
|
||||
break
|
||||
except AttributeError:
|
||||
continue
|
||||
section_and_time_headers = {
|
||||
**conn.client.headers,
|
||||
"Referer": JWCConfig().to_full_url(ENDPOINTS["student_schedule"]),
|
||||
}
|
||||
select_and_time_url = JWCConfig().to_full_url(ENDPOINTS["section_and_time"])
|
||||
select_and_time_data = {
|
||||
"planNumber": "",
|
||||
"ff": "f",
|
||||
"sf_request_type": "ajax",
|
||||
}
|
||||
section_and_time_response_coro = conn.client.post(
|
||||
select_and_time_url,
|
||||
data=select_and_time_data,
|
||||
headers=section_and_time_headers,
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
student_schedule_url = JWCConfig().to_full_url(
|
||||
ENDPOINTS["student_schedule"].format(dynamic_path=dynamic_path)
|
||||
)
|
||||
|
||||
schedule_params = {
|
||||
"planCode": term_code,
|
||||
"sf_request_type": "ajax",
|
||||
}
|
||||
student_schedule_response_coro = conn.client.get(
|
||||
student_schedule_url,
|
||||
params=schedule_params,
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
section_and_time_response, student_schedule_response = await asyncio.gather(
|
||||
section_and_time_response_coro, student_schedule_response_coro
|
||||
)
|
||||
if section_and_time_response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取节次时间信息失败,状态码: {section_and_time_response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode.remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, message="无法获取节次时间信息,请稍后再试"
|
||||
)
|
||||
if student_schedule_response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取课表信息失败,状态码: {student_schedule_response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode.remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, message="无法获取课表信息,请稍后再试"
|
||||
)
|
||||
time_data = section_and_time_response.json()
|
||||
schedule_data = student_schedule_response.json()
|
||||
|
||||
# 处理时间段信息
|
||||
time_slots = []
|
||||
section_time = time_data.get("sectionTime", [])
|
||||
for time_slot in section_time:
|
||||
time_slots.append(
|
||||
{
|
||||
"session": time_slot.get("id", {}).get("session", 0),
|
||||
"session_name": time_slot.get("sessionName", ""),
|
||||
"start_time": time_slot.get("startTime", ""),
|
||||
"end_time": time_slot.get("endTime", ""),
|
||||
"time_length": time_slot.get("timeLength", ""),
|
||||
"djjc": time_slot.get("djjc", 0),
|
||||
}
|
||||
)
|
||||
|
||||
# 处理课程信息
|
||||
courses = []
|
||||
xkxx_list = schedule_data.get("xkxx", [])
|
||||
|
||||
for xkxx_item in xkxx_list:
|
||||
if isinstance(xkxx_item, dict):
|
||||
for course_key, course_data in xkxx_item.items():
|
||||
if isinstance(course_data, dict):
|
||||
# 提取基本课程信息
|
||||
course_name = course_data.get("courseName", "")
|
||||
course_code = course_data.get("id", {}).get("coureNumber", "")
|
||||
course_sequence = course_data.get("id", {}).get(
|
||||
"coureSequenceNumber", ""
|
||||
)
|
||||
teacher_name = (
|
||||
course_data.get("attendClassTeacher", "")
|
||||
.replace("* ", "")
|
||||
.strip()
|
||||
)
|
||||
course_properties = course_data.get("coursePropertiesName", "")
|
||||
exam_type = course_data.get("examTypeName", "")
|
||||
unit = float(course_data.get("unit", 0))
|
||||
|
||||
# 处理时间地点列表
|
||||
time_locations = []
|
||||
time_place_list = course_data.get("timeAndPlaceList", [])
|
||||
|
||||
# 检查是否有具体时间安排
|
||||
is_no_schedule = len(time_place_list) == 0
|
||||
|
||||
for time_place in time_place_list:
|
||||
# 过滤掉无用的字段,只保留关键信息
|
||||
time_location = {
|
||||
"class_day": time_place.get("classDay", 0),
|
||||
"class_sessions": time_place.get("classSessions", 0),
|
||||
"continuing_session": time_place.get(
|
||||
"continuingSession", 0
|
||||
),
|
||||
"class_week": time_place.get("classWeek", ""),
|
||||
"week_description": time_place.get(
|
||||
"weekDescription", ""
|
||||
),
|
||||
"campus_name": time_place.get("campusName", ""),
|
||||
"teaching_building_name": time_place.get(
|
||||
"teachingBuildingName", ""
|
||||
),
|
||||
"classroom_name": time_place.get("classroomName", ""),
|
||||
}
|
||||
time_locations.append(time_location)
|
||||
|
||||
# 只保留有效的课程(有课程名称的)
|
||||
if course_name:
|
||||
course = {
|
||||
"course_name": course_name,
|
||||
"course_code": course_code,
|
||||
"course_sequence": course_sequence,
|
||||
"teacher_name": teacher_name,
|
||||
"course_properties": course_properties,
|
||||
"exam_type": exam_type,
|
||||
"unit": unit,
|
||||
"time_locations": time_locations,
|
||||
"is_no_schedule": is_no_schedule,
|
||||
}
|
||||
courses.append(course)
|
||||
# 构建最终数据
|
||||
processed_data = {
|
||||
"total_units": float(schedule_data.get("allUnits", 0)),
|
||||
"time_slots": time_slots,
|
||||
"courses": courses,
|
||||
}
|
||||
|
||||
conn.logger.info(
|
||||
f"成功处理课表数据:共{len(courses)}门课程,{len(time_slots)}个时间段"
|
||||
)
|
||||
result = ScheduleData.model_validate(processed_data)
|
||||
return UniResponseModel[ScheduleData](
|
||||
success=True,
|
||||
data=result,
|
||||
message="获取课表信息成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"数据验证错误: {ve}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id, "数据验证错误"
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id,
|
||||
)
|
||||
176
loveace/router/endpoint/jwc/score.py
Normal file
176
loveace/router/endpoint/jwc/score.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import re
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from httpx import HTTPError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from loveace.router.endpoint.jwc.model.base import JWCConfig
|
||||
from loveace.router.endpoint.jwc.model.score import ScoreRecord, TermScoreResponse
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
jwc_score_router = APIRouter(
|
||||
prefix="/score",
|
||||
responses=ProtectRouterErrorToCode().gen_code_table(),
|
||||
)
|
||||
|
||||
ENDPOINT = {
|
||||
"term_score_pre": "/student/integratedQuery/scoreQuery/allTermScores/index",
|
||||
"term_score": "/student/integratedQuery/scoreQuery/{dynamic_path}/allTermScores/data",
|
||||
}
|
||||
|
||||
|
||||
@jwc_score_router.get(
|
||||
"/{term_code}/list",
|
||||
summary="获取给定学期成绩列表",
|
||||
response_model=UniResponseModel[TermScoreResponse],
|
||||
)
|
||||
async def get_term_score(
|
||||
term_code: str,
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[TermScoreResponse] | JSONResponse:
|
||||
"""
|
||||
获取指定学期的详细成绩单
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取指定学期所有课程成绩
|
||||
- 包含补考和重修成绩
|
||||
- 显示学分、绩点等详细信息
|
||||
|
||||
💡 使用场景:
|
||||
- 查看历史学期的成绩
|
||||
- 导出成绩单
|
||||
- 分析学业成绩趋势
|
||||
|
||||
Args:
|
||||
term_code: 学期代码(如:2023-2024-1)
|
||||
|
||||
Returns:
|
||||
TermScoreResponse: 包含该学期所有成绩记录和总数
|
||||
"""
|
||||
try:
|
||||
response = await conn.client.get(
|
||||
JWCConfig().to_full_url(ENDPOINT["term_score_pre"]),
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(f"访问成绩查询页面失败,状态码: {response.status_code}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
# 从页面中提取动态路径参数
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
# 查找表单或Ajax请求的URL
|
||||
# 通常在JavaScript代码中或表单action中
|
||||
dynamic_path = "M1uwxk14o6" # 默认值,如果无法提取则使用
|
||||
|
||||
# 尝试从页面中提取动态路径
|
||||
scripts = soup.find_all("script")
|
||||
for script in scripts:
|
||||
try:
|
||||
script_text = script.string # type: ignore
|
||||
if script_text and "allTermScores/data" in script_text:
|
||||
# 使用正则表达式提取路径
|
||||
match = re.search(
|
||||
r"/([A-Za-z0-9]+)/allTermScores/data", script_text
|
||||
)
|
||||
if match:
|
||||
dynamic_path = match.group(1)
|
||||
break
|
||||
except AttributeError:
|
||||
continue
|
||||
|
||||
data_url = JWCConfig().to_full_url(
|
||||
ENDPOINT["term_score"].format(dynamic_path=dynamic_path)
|
||||
)
|
||||
data_params = {
|
||||
"zxjxjhh": term_code,
|
||||
"kch": "",
|
||||
"kcm": "",
|
||||
"pageNum": "1",
|
||||
"pageSize": "50",
|
||||
"sf_request_type": "ajax",
|
||||
}
|
||||
data_response = await conn.client.post(
|
||||
data_url,
|
||||
data=data_params,
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
if data_response.status_code != 200:
|
||||
conn.logger.error(f"获取成绩数据失败,状态码: {data_response.status_code}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
data_json = data_response.json()
|
||||
data_list = data_json.get("list", {})
|
||||
if not data_list:
|
||||
result = TermScoreResponse(records=[], total_count=0)
|
||||
return UniResponseModel[TermScoreResponse](
|
||||
success=True,
|
||||
data=result,
|
||||
message="获取成绩单成功",
|
||||
error=None,
|
||||
)
|
||||
records = data_list.get("records", [])
|
||||
r_total_count = data_list.get("pageContext", {}).get("totalCount", 0)
|
||||
term_scores = []
|
||||
for record in records:
|
||||
term_scores.append(
|
||||
ScoreRecord(
|
||||
sequence=record[0],
|
||||
term_id=record[1],
|
||||
course_code=record[2],
|
||||
course_class=record[3],
|
||||
course_name_cn=record[4],
|
||||
course_name_en=record[5],
|
||||
credits=record[6],
|
||||
hours=record[7],
|
||||
course_type=record[8],
|
||||
exam_type=record[9],
|
||||
score=record[10],
|
||||
retake_score=record[11] if record[11] else None,
|
||||
makeup_score=record[12] if record[12] else None,
|
||||
)
|
||||
)
|
||||
l_total_count = len(term_scores)
|
||||
assert r_total_count == l_total_count
|
||||
result = TermScoreResponse(records=term_scores, total_count=r_total_count)
|
||||
return UniResponseModel[TermScoreResponse](
|
||||
success=True,
|
||||
data=result,
|
||||
message="获取成绩单成功",
|
||||
error=None,
|
||||
)
|
||||
except AssertionError as ae:
|
||||
conn.logger.error(f"数据属性错误: {ae}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except IndexError as ie:
|
||||
conn.logger.error(f"数据解析错误: {ie}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"数据验证错误: {ve}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"HTTP请求错误: {he}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
306
loveace/router/endpoint/jwc/term.py
Normal file
306
loveace/router/endpoint/jwc/term.py
Normal file
@@ -0,0 +1,306 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from httpx import HTTPError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from loveace.router.endpoint.jwc.model.base import JWCConfig
|
||||
from loveace.router.endpoint.jwc.model.term import CurrentTermInfo, TermItem
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
jwc_term_router = APIRouter(
|
||||
prefix="/term",
|
||||
responses=ProtectRouterErrorToCode().gen_code_table(),
|
||||
)
|
||||
|
||||
ENDPOINT = {
|
||||
"all_terms": "/student/courseSelect/calendarSemesterCurriculum/index",
|
||||
"calendar": "/indexCalendar",
|
||||
}
|
||||
|
||||
|
||||
@jwc_term_router.get(
|
||||
"/all",
|
||||
summary="获取所有学期信息",
|
||||
response_model=UniResponseModel[list[TermItem]],
|
||||
)
|
||||
async def get_all_terms(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[list[TermItem]] | JSONResponse:
|
||||
"""
|
||||
获取用户可选的所有学期列表
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取从入学至今的所有学期
|
||||
- 标记当前学期
|
||||
- 学期名称格式统一处理
|
||||
|
||||
💡 使用场景:
|
||||
- 选课系统的学期选择菜单
|
||||
- 成绩查询的学期选择
|
||||
- 课程表查询的学期选择
|
||||
|
||||
Returns:
|
||||
list[TermItem]: 学期列表,包含学期代码、名称、是否为当前学期
|
||||
"""
|
||||
try:
|
||||
all_terms = []
|
||||
response = await conn.client.get(
|
||||
JWCConfig().to_full_url(ENDPOINT["all_terms"]),
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(f"获取学期信息失败,状态码: {response.status_code}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
# 解析HTML获取学期选项
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
|
||||
# 查找学期选择下拉框
|
||||
select_element = soup.find("select", {"id": "planCode"})
|
||||
if not select_element:
|
||||
conn.logger.error("未找到学期选择框")
|
||||
return UniResponseModel[list[TermItem]](
|
||||
success=False,
|
||||
data=[],
|
||||
message="未找到学期选择框",
|
||||
error=None,
|
||||
)
|
||||
|
||||
terms = {}
|
||||
# 使用更安全的方式处理选项
|
||||
try:
|
||||
options = select_element.find_all("option") # type: ignore
|
||||
for option in options:
|
||||
value = option.get("value") # type: ignore
|
||||
text = option.get_text(strip=True) # type: ignore
|
||||
|
||||
# 跳过空值选项(如"全部")
|
||||
if value and str(value).strip() and text != "全部":
|
||||
terms[str(value)] = text
|
||||
except AttributeError:
|
||||
conn.logger.error("解析学期选项失败")
|
||||
return UniResponseModel[list[TermItem]](
|
||||
success=False,
|
||||
data=[],
|
||||
message="解析学期选项失败",
|
||||
error=None,
|
||||
)
|
||||
|
||||
conn.logger.info(f"成功获取{len(terms)}个学期信息")
|
||||
counter = 0
|
||||
# 遍历学期选项,提取学期代码和名称
|
||||
# 将学期中的 "春" 替换为 "下" , "秋" 替换为 "上"
|
||||
for key, value in terms.items():
|
||||
counter += 1
|
||||
value = value.replace("春", "下").replace("秋", "上")
|
||||
all_terms.append(
|
||||
TermItem(term_code=key, term_name=value, is_current=counter == 1)
|
||||
)
|
||||
|
||||
return UniResponseModel[list[TermItem]](
|
||||
success=True,
|
||||
data=all_terms,
|
||||
message="获取学期信息成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"数据验证错误: {ve}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"HTTP请求错误: {he}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
|
||||
@jwc_term_router.get(
|
||||
"/current",
|
||||
summary="获取当前学期信息",
|
||||
response_model=UniResponseModel[CurrentTermInfo],
|
||||
)
|
||||
async def get_current_term(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
) -> UniResponseModel[CurrentTermInfo] | JSONResponse:
|
||||
"""
|
||||
获取当前学期的详细信息
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取当前学期的开始和结束日期
|
||||
- 获取学期周数信息
|
||||
- 实时从教务系统获取
|
||||
|
||||
💡 使用场景:
|
||||
- 显示当前学期进度
|
||||
- 课程表的周次显示参考
|
||||
- 学期时间提醒
|
||||
|
||||
Returns:
|
||||
CurrentTermInfo: 包含学期代码、名称、开始日期、结束日期等
|
||||
"""
|
||||
try:
|
||||
info_response = await conn.client.get(
|
||||
JWCConfig().DEFAULT_BASE_URL, follow_redirects=True, timeout=conn.timeout
|
||||
)
|
||||
if info_response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取学期信息页面失败,状态码: {info_response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
|
||||
start_response = await conn.client.get(
|
||||
JWCConfig().to_full_url(ENDPOINT["calendar"]),
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
if start_response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取学期开始时间失败,状态码: {start_response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
# 提取学期开始时间
|
||||
flexible_pattern = r'var\s+rq\s*=\s*"(\d{8})";\s*//.*'
|
||||
match = re.findall(flexible_pattern, start_response.text)
|
||||
if not match:
|
||||
conn.logger.error("未找到学期开始时间")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
start_date_str = match[0]
|
||||
try:
|
||||
start_date = datetime.strptime(start_date_str, "%Y%m%d").date()
|
||||
except ValueError:
|
||||
conn.logger.error(f"学期开始时间格式错误: {start_date_str}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
start_date = datetime.strptime(start_date_str, "%Y%m%d").date()
|
||||
|
||||
html_content = info_response.text
|
||||
|
||||
# 使用BeautifulSoup解析HTML
|
||||
soup = BeautifulSoup(html_content, "html.parser")
|
||||
|
||||
# 查找包含学期周数信息的元素
|
||||
# 使用CSS选择器查找
|
||||
calendar_element = soup.select_one(
|
||||
"#navbar-container > div.navbar-buttons.navbar-header.pull-right > ul > li.light-red > a"
|
||||
)
|
||||
|
||||
if not calendar_element:
|
||||
# 如果CSS选择器失败,尝试其他方法
|
||||
# 查找包含"第X周"的元素
|
||||
potential_elements = soup.find_all("a", class_="dropdown-toggle")
|
||||
calendar_element = None
|
||||
|
||||
for element in potential_elements:
|
||||
text = element.get_text(strip=True) if element else ""
|
||||
if "第" in text and "周" in text:
|
||||
calendar_element = element
|
||||
break
|
||||
|
||||
# 如果还是找不到,尝试查找任何包含学期信息的元素
|
||||
if not calendar_element:
|
||||
all_elements = soup.find_all(text=re.compile(r"\d{4}-\d{4}.*第\d+周"))
|
||||
if all_elements:
|
||||
# 找到包含学期信息的文本,查找其父元素
|
||||
for text_node in all_elements:
|
||||
parent = text_node.parent
|
||||
if parent:
|
||||
calendar_element = parent
|
||||
break
|
||||
|
||||
if not calendar_element:
|
||||
conn.logger.warning("未找到学期周数信息元素")
|
||||
|
||||
# 尝试在整个页面中搜索学期信息模式
|
||||
semester_pattern = re.search(
|
||||
r"(\d{4}-\d{4})\s*(春|秋|夏)?\s*第(\d+)周\s*(星期[一二三四五六日天])?",
|
||||
html_content,
|
||||
)
|
||||
if semester_pattern:
|
||||
calendar_text = semester_pattern.group(0)
|
||||
conn.logger.info(f"通过正则表达式找到学期信息: {calendar_text}")
|
||||
else:
|
||||
conn.logger.debug(f"HTML内容长度: {len(html_content)}")
|
||||
conn.logger.debug(
|
||||
"未检测到学期周数相关内容,可能需要重新登录或检查访问权限"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
else:
|
||||
# 提取文本内容
|
||||
calendar_text = calendar_element.get_text(strip=True)
|
||||
conn.logger.info(f"找到学期周数信息: {calendar_text}")
|
||||
clean_text = re.sub(r"\s+", " ", calendar_text.strip())
|
||||
|
||||
# 初始化默认值
|
||||
academic_year = ""
|
||||
term = ""
|
||||
week_number = 0
|
||||
is_end = False
|
||||
|
||||
try:
|
||||
# 解析学年:2025-2026
|
||||
year_match = re.search(r"(\d{4}-\d{4})", clean_text)
|
||||
if year_match:
|
||||
academic_year = year_match.group(1)
|
||||
|
||||
# 解析学期:秋、春
|
||||
semester_match = re.search(r"(春|秋)", clean_text)
|
||||
if semester_match:
|
||||
term = semester_match.group(1)
|
||||
|
||||
# 解析周数:第1周、第15周等
|
||||
week_match = re.search(r"第(\d+)周", clean_text)
|
||||
if week_match:
|
||||
week_number = int(week_match.group(1))
|
||||
|
||||
# 判断是否为学期结束(通常第16周以后或包含"结束"等关键词)
|
||||
if week_number >= 16 or "结束" in clean_text or "考试" in clean_text:
|
||||
is_end = True
|
||||
|
||||
except Exception as e:
|
||||
conn.logger.warning(f"解析学期周数信息时出错: {str(e)}")
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
result = CurrentTermInfo(
|
||||
academic_year=academic_year,
|
||||
current_term_name=term,
|
||||
week_number=week_number,
|
||||
start_at=start_date.strftime("%Y-%m-%d"),
|
||||
is_end=is_end,
|
||||
weekday=datetime.now().weekday(),
|
||||
)
|
||||
return UniResponseModel[CurrentTermInfo](
|
||||
success=True,
|
||||
data=result,
|
||||
message="获取当前学期信息成功",
|
||||
error=None,
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.exception(e)
|
||||
return ProtectRouterErrorToCode().server_error.to_json_response(
|
||||
conn.logger.trace_id
|
||||
)
|
||||
96
loveace/router/endpoint/jwc/utils/aspnet_form_parser.py
Normal file
96
loveace/router/endpoint/jwc/utils/aspnet_form_parser.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
ASP.NET 表单解析器
|
||||
用于从 ASP.NET 页面中提取动态表单数据
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
class ASPNETFormParser:
|
||||
"""ASP.NET 表单解析器"""
|
||||
|
||||
@staticmethod
|
||||
def extract_form_data(html_content: str) -> Dict[str, str]:
|
||||
"""
|
||||
从 ASP.NET 页面 HTML 中提取表单数据
|
||||
|
||||
Args:
|
||||
html_content: HTML 页面内容
|
||||
|
||||
Returns:
|
||||
包含表单字段的字典
|
||||
"""
|
||||
|
||||
return ASPNETFormParser._extract_with_beautifulsoup(html_content)
|
||||
|
||||
@staticmethod
|
||||
def _extract_with_beautifulsoup(html_content: str) -> Dict[str, str]:
|
||||
"""
|
||||
使用 BeautifulSoup 提取表单数据
|
||||
|
||||
Args:
|
||||
html_content: HTML 页面内容
|
||||
|
||||
Returns:
|
||||
包含表单字段的字典
|
||||
"""
|
||||
form_data = {}
|
||||
|
||||
# 使用 BeautifulSoup 解析 HTML
|
||||
soup = BeautifulSoup(html_content, "lxml")
|
||||
|
||||
# 查找表单
|
||||
form = soup.find("form", {"method": "post"})
|
||||
if not form:
|
||||
raise ValueError("未找到 POST 表单")
|
||||
|
||||
# 提取隐藏字段
|
||||
hidden_fields = [
|
||||
"__EVENTTARGET",
|
||||
"__EVENTARGUMENT",
|
||||
"__LASTFOCUS",
|
||||
"__VIEWSTATE",
|
||||
"__VIEWSTATEGENERATOR",
|
||||
"__EVENTVALIDATION",
|
||||
]
|
||||
|
||||
for field_name in hidden_fields:
|
||||
input_element = form.find("input", {"name": field_name})
|
||||
if input_element and input_element.get("value"):
|
||||
form_data[field_name] = input_element.get("value")
|
||||
else:
|
||||
form_data[field_name] = ""
|
||||
|
||||
# 添加其他表单字段的默认值
|
||||
form_data.update(
|
||||
{
|
||||
"ctl00$ContentPlaceHolder1$ContentPlaceHolder2$ddlSslb": "%",
|
||||
"ctl00$ContentPlaceHolder1$ContentPlaceHolder2$txtSsmc": "",
|
||||
"ctl00$ContentPlaceHolder1$ContentPlaceHolder2$gvSb$ctl28$txtNewPageIndex": "1",
|
||||
}
|
||||
)
|
||||
|
||||
return form_data
|
||||
|
||||
@staticmethod
|
||||
def get_awards_list_form_data(html_content: str) -> Dict[str, str]:
|
||||
"""
|
||||
获取已申报奖项列表页面的表单数据
|
||||
|
||||
Args:
|
||||
html_content: HTML 页面内容
|
||||
|
||||
Returns:
|
||||
用于请求已申报奖项的表单数据
|
||||
"""
|
||||
base_form_data = ASPNETFormParser.extract_form_data(html_content)
|
||||
|
||||
# 设置 EVENTTARGET 为"已申报奖项"选项卡
|
||||
base_form_data["__EVENTTARGET"] = (
|
||||
"ctl00$ContentPlaceHolder1$ContentPlaceHolder2$DataList1$ctl01$LinkButton1"
|
||||
)
|
||||
|
||||
return base_form_data
|
||||
267
loveace/router/endpoint/jwc/utils/competition.py
Normal file
267
loveace/router/endpoint/jwc/utils/competition.py
Normal file
@@ -0,0 +1,267 @@
|
||||
from typing import Optional
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from loveace.router.endpoint.jwc.model.competition import (
|
||||
AwardProject,
|
||||
CompetitionAwardsResponse,
|
||||
CompetitionCreditsSummaryResponse,
|
||||
CompetitionFullResponse,
|
||||
CreditsSummary,
|
||||
)
|
||||
|
||||
|
||||
class CompetitionInfoParser:
|
||||
"""
|
||||
创新创业管理平台信息解析器
|
||||
|
||||
功能:
|
||||
- 解析获奖项目列表(表格数据)
|
||||
- 解析学分汇总信息
|
||||
- 提取学生基本信息
|
||||
"""
|
||||
|
||||
def __init__(self, html_content: str):
|
||||
"""
|
||||
初始化解析器
|
||||
|
||||
参数:
|
||||
html_content: HTML页面内容字符串
|
||||
"""
|
||||
self.soup = BeautifulSoup(html_content, "html.parser")
|
||||
|
||||
def parse_awards(self) -> CompetitionAwardsResponse:
|
||||
"""
|
||||
解析获奖项目列表
|
||||
|
||||
返回:
|
||||
CompetitionAwardsResponse: 包含获奖项目列表的响应对象
|
||||
"""
|
||||
# 解析学生ID
|
||||
student_id = self._parse_student_id()
|
||||
|
||||
# 解析项目列表
|
||||
projects = self._parse_projects()
|
||||
|
||||
response = CompetitionAwardsResponse(
|
||||
student_id=student_id,
|
||||
total_count=len(projects),
|
||||
awards=projects,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def parse_credits_summary(self) -> CompetitionCreditsSummaryResponse:
|
||||
"""
|
||||
解析学分汇总信息
|
||||
|
||||
返回:
|
||||
CompetitionCreditsSummaryResponse: 包含学分汇总信息的响应对象
|
||||
"""
|
||||
# 解析学生ID
|
||||
student_id = self._parse_student_id()
|
||||
|
||||
# 解析学分汇总
|
||||
credits_summary = self._parse_credits_summary()
|
||||
|
||||
response = CompetitionCreditsSummaryResponse(
|
||||
student_id=student_id,
|
||||
credits_summary=credits_summary,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def parse_full_competition_info(self) -> CompetitionFullResponse:
|
||||
"""
|
||||
解析完整的学科竞赛信息(获奖项目 + 学分汇总)
|
||||
|
||||
一次性解析HTML,同时提取获奖项目列表和学分汇总信息,
|
||||
减少网络IO和数据库查询次数
|
||||
|
||||
返回:
|
||||
CompetitionFullResponse: 包含完整竞赛信息的响应对象
|
||||
"""
|
||||
# 解析学生ID
|
||||
student_id = self._parse_student_id()
|
||||
|
||||
# 解析项目列表
|
||||
projects = self._parse_projects()
|
||||
|
||||
# 解析学分汇总
|
||||
credits_summary = self._parse_credits_summary()
|
||||
|
||||
response = CompetitionFullResponse(
|
||||
student_id=student_id,
|
||||
total_awards_count=len(projects),
|
||||
awards=projects,
|
||||
credits_summary=credits_summary,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def _parse_student_id(self) -> str:
|
||||
"""
|
||||
解析学生基本信息 - 学生ID/工号
|
||||
|
||||
返回:
|
||||
str: 学生ID,如果未找到返回空字符串
|
||||
"""
|
||||
student_span = self.soup.find("span", id="ContentPlaceHolder1_lblXM")
|
||||
if student_span:
|
||||
text = student_span.get_text(strip=True)
|
||||
# 格式: "欢迎您:20244787"
|
||||
if ":" in text:
|
||||
return text.split(":")[1].strip()
|
||||
return ""
|
||||
|
||||
def _parse_projects(self) -> list:
|
||||
"""
|
||||
解析获奖项目列表
|
||||
|
||||
数据来源: 页面中ID为 ContentPlaceHolder1_ContentPlaceHolder2_gvHj 的表格
|
||||
|
||||
表格结构:
|
||||
- 第一行为表头
|
||||
- 后续行为项目数据
|
||||
- 包含15列数据
|
||||
|
||||
返回:
|
||||
list[AwardProject]: 获奖项目列表
|
||||
"""
|
||||
projects = []
|
||||
|
||||
# 查找项目列表表格
|
||||
table = self.soup.find(
|
||||
"table", id="ContentPlaceHolder1_ContentPlaceHolder2_gvHj"
|
||||
)
|
||||
if not table:
|
||||
return projects
|
||||
|
||||
rows = table.find_all("tr")
|
||||
# 跳过表头行(第一行)
|
||||
for row in rows[1:]:
|
||||
cells = row.find_all("td")
|
||||
if len(cells) < 9: # 至少需要9列数据
|
||||
continue
|
||||
|
||||
try:
|
||||
project = AwardProject(
|
||||
project_id=cells[0].get_text(strip=True),
|
||||
project_name=cells[1].get_text(strip=True),
|
||||
level=cells[2].get_text(strip=True),
|
||||
grade=cells[3].get_text(strip=True),
|
||||
award_date=cells[4].get_text(strip=True),
|
||||
applicant_id=cells[5].get_text(strip=True),
|
||||
applicant_name=cells[6].get_text(strip=True),
|
||||
order=int(cells[7].get_text(strip=True)),
|
||||
credits=float(cells[8].get_text(strip=True)),
|
||||
bonus=float(cells[9].get_text(strip=True)),
|
||||
status=cells[10].get_text(strip=True),
|
||||
verification_status=cells[11].get_text(strip=True),
|
||||
)
|
||||
projects.append(project)
|
||||
except (ValueError, IndexError):
|
||||
# 数据格式异常,记录但继续处理
|
||||
continue
|
||||
|
||||
return projects
|
||||
|
||||
def _parse_credits_summary(self) -> Optional[CreditsSummary]:
|
||||
"""
|
||||
解析学分汇总信息
|
||||
|
||||
数据来源: 页面中的学分汇总表中的各类学分 span 元素
|
||||
|
||||
提取内容:
|
||||
- 学科竞赛学分
|
||||
- 科研项目学分
|
||||
- 可转竞赛类学分
|
||||
- 创新创业实践学分
|
||||
- 能力资格认证学分
|
||||
- 其他项目学分
|
||||
|
||||
返回:
|
||||
CreditsSummary: 学分汇总对象,如果无法解析则返回 None
|
||||
"""
|
||||
discipline_competition_credits = None
|
||||
scientific_research_credits = None
|
||||
transferable_competition_credits = None
|
||||
innovation_practice_credits = None
|
||||
ability_certification_credits = None
|
||||
other_project_credits = None
|
||||
|
||||
# 查找学科竞赛学分
|
||||
xkjs_span = self.soup.find(
|
||||
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblXkjsxf"
|
||||
)
|
||||
if xkjs_span:
|
||||
text = xkjs_span.get_text(strip=True)
|
||||
discipline_competition_credits = self._parse_credit_value(text)
|
||||
|
||||
# 查找科研项目学分
|
||||
ky_span = self.soup.find(
|
||||
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblKyxf"
|
||||
)
|
||||
if ky_span:
|
||||
text = ky_span.get_text(strip=True)
|
||||
scientific_research_credits = self._parse_credit_value(text)
|
||||
|
||||
# 查找可转竞赛类学分
|
||||
kzjsl_span = self.soup.find(
|
||||
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblKzjslxf"
|
||||
)
|
||||
if kzjsl_span:
|
||||
text = kzjsl_span.get_text(strip=True)
|
||||
transferable_competition_credits = self._parse_credit_value(text)
|
||||
|
||||
# 查找创新创业实践学分
|
||||
cxcy_span = self.soup.find(
|
||||
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblCxcyxf"
|
||||
)
|
||||
if cxcy_span:
|
||||
text = cxcy_span.get_text(strip=True)
|
||||
innovation_practice_credits = self._parse_credit_value(text)
|
||||
|
||||
# 查找能力资格认证学分
|
||||
nlzg_span = self.soup.find(
|
||||
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblNlzgxf"
|
||||
)
|
||||
if nlzg_span:
|
||||
text = nlzg_span.get_text(strip=True)
|
||||
ability_certification_credits = self._parse_credit_value(text)
|
||||
|
||||
# 查找其他项目学分
|
||||
qt_span = self.soup.find(
|
||||
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblQtxf"
|
||||
)
|
||||
if qt_span:
|
||||
text = qt_span.get_text(strip=True)
|
||||
other_project_credits = self._parse_credit_value(text)
|
||||
|
||||
return CreditsSummary(
|
||||
discipline_competition_credits=discipline_competition_credits,
|
||||
scientific_research_credits=scientific_research_credits,
|
||||
transferable_competition_credits=transferable_competition_credits,
|
||||
innovation_practice_credits=innovation_practice_credits,
|
||||
ability_certification_credits=ability_certification_credits,
|
||||
other_project_credits=other_project_credits,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_credit_value(text: str) -> Optional[float]:
|
||||
"""
|
||||
解析学分值
|
||||
|
||||
参数:
|
||||
text: 文本值,可能为"0", "16.60", "无"等
|
||||
|
||||
返回:
|
||||
float: 学分值,如果为"无"或无法解析则返回 None
|
||||
"""
|
||||
text = text.strip()
|
||||
if text == "无" or text == "":
|
||||
return None
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
337
loveace/router/endpoint/jwc/utils/exam.py
Normal file
337
loveace/router/endpoint/jwc/utils/exam.py
Normal file
@@ -0,0 +1,337 @@
|
||||
import time
|
||||
from json import JSONDecodeError
|
||||
from typing import List, Optional
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from loveace.router.endpoint.jwc.model.base import JWCConfig
|
||||
from loveace.router.endpoint.jwc.model.exam import (
|
||||
ExamInfoResponse,
|
||||
ExamScheduleItem,
|
||||
OtherExamRecord,
|
||||
OtherExamResponse,
|
||||
SeatInfo,
|
||||
UnifiedExamInfo,
|
||||
)
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
|
||||
ENDPOINTS = {
|
||||
"school_exam_pre_request": "/student/examinationManagement/examPlan/index",
|
||||
"school_exam_request": "/student/examinationManagement/examPlan/detail",
|
||||
"seat_info": "/student/examinationManagement/examPlan/index",
|
||||
"other_exam_record": "/student/examinationManagement/othersExamPlan/queryScores?sf_request_type=ajax",
|
||||
}
|
||||
|
||||
|
||||
# +++++===== 考试信息前置方法 =====+++++ #
|
||||
async def fetch_school_exam_schedule(
|
||||
start_date: str, end_date: str, conn: AUFEConnection
|
||||
) -> List[ExamScheduleItem]:
|
||||
"""
|
||||
获取校统考考试安排
|
||||
|
||||
Args:
|
||||
start_date: 开始日期 (YYYY-MM-DD)
|
||||
end_date: 结束日期 (YYYY-MM-DD)
|
||||
|
||||
Returns:
|
||||
List[ExamScheduleItem]: 校统考列表
|
||||
"""
|
||||
try:
|
||||
timestamp = int(time.time() * 1000)
|
||||
|
||||
headers = {
|
||||
**conn.client.headers,
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
}
|
||||
|
||||
params = {
|
||||
"start": start_date,
|
||||
"end": end_date,
|
||||
"_": str(timestamp),
|
||||
"sf_request_type": "ajax",
|
||||
}
|
||||
await conn.client.get(
|
||||
url=JWCConfig().to_full_url(ENDPOINTS["school_exam_pre_request"]),
|
||||
follow_redirects=True,
|
||||
headers=headers,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
response = await conn.client.get(
|
||||
url=JWCConfig().to_full_url(ENDPOINTS["school_exam_request"]),
|
||||
headers=headers,
|
||||
params=params,
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(f"获取校统考信息失败: HTTP状态码 {response.status_code}")
|
||||
return []
|
||||
if "]" == response.text:
|
||||
conn.logger.warning("获取校统考信息成功,但无数据")
|
||||
return []
|
||||
try:
|
||||
json_data = response.json()
|
||||
except JSONDecodeError as e:
|
||||
conn.logger.error(f"解析校统考信息JSON失败: {str(e)}")
|
||||
return []
|
||||
|
||||
# 解析为ExamScheduleItem列表
|
||||
school_exams = []
|
||||
if isinstance(json_data, list):
|
||||
for item in json_data:
|
||||
exam_item = ExamScheduleItem.model_validate(item)
|
||||
school_exams.append(exam_item)
|
||||
|
||||
conn.logger.info(f"获取校统考信息成功,共 {len(school_exams)} 场考试")
|
||||
return school_exams
|
||||
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取校统考信息出现如下异常: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_exam_seat_info(conn: AUFEConnection) -> List[SeatInfo]:
|
||||
"""
|
||||
获取考试座位号信息
|
||||
conn: AUFEConnection
|
||||
|
||||
Returns:
|
||||
List[SeatInfo]: 座位信息列表
|
||||
"""
|
||||
try:
|
||||
headers = {
|
||||
**conn.client.headers,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
}
|
||||
|
||||
response = await conn.client.get(
|
||||
url=JWCConfig().to_full_url(ENDPOINTS["seat_info"]),
|
||||
headers=headers,
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取考试座位号信息失败: HTTP状态码 {response.status_code}"
|
||||
)
|
||||
return []
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
seat_infos = []
|
||||
|
||||
# 查找所有考试信息区块
|
||||
exam_blocks = soup.find_all("div", {"class": "widget-box"})
|
||||
for block in exam_blocks:
|
||||
course_name = ""
|
||||
seat_number = ""
|
||||
|
||||
# 获取课程名
|
||||
title = block.find("h5", {"class": "widget-title"}) # type: ignore
|
||||
if title:
|
||||
course_text = title.get_text(strip=True) # type: ignore
|
||||
# 提取课程名,格式可能是: "(课程代码-班号)课程名"
|
||||
if ")" in course_text:
|
||||
course_name = course_text.split(")", 1)[1].strip()
|
||||
else:
|
||||
course_name = course_text.strip()
|
||||
|
||||
# 获取座位号
|
||||
widget_main = block.find("div", {"class": "widget-main"}) # type: ignore
|
||||
if widget_main:
|
||||
content = widget_main.get_text() # type: ignore
|
||||
for line in content.split("\n"):
|
||||
if "座位号" in line:
|
||||
try:
|
||||
seat_number = line.split("座位号:")[1].strip()
|
||||
except Exception:
|
||||
try:
|
||||
seat_number = line.split("座位号:")[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
if course_name and seat_number:
|
||||
seat_infos.append(
|
||||
SeatInfo(course_name=course_name, seat_number=seat_number)
|
||||
)
|
||||
|
||||
conn.logger.info(f"获取考试座位号信息成功,共 {len(seat_infos)} 条记录")
|
||||
return seat_infos
|
||||
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取考试座位号信息异常: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def convert_school_exam_to_unified(
|
||||
exam: ExamScheduleItem, seat_infos: List[SeatInfo], conn: AUFEConnection
|
||||
) -> Optional[UnifiedExamInfo]:
|
||||
"""
|
||||
将校统考数据转换为统一格式
|
||||
|
||||
Args:
|
||||
exam: 校统考项目
|
||||
seat_info: 座位号信息映射
|
||||
|
||||
Returns:
|
||||
Optional[UnifiedExamInfo]: 统一格式的考试信息
|
||||
"""
|
||||
try:
|
||||
# 解析title信息,格式如: "新媒体导论\n08:30-10:30\n西校\n西校通慧楼\n通慧楼-308\n"
|
||||
title_parts = exam.title.strip().split("\n")
|
||||
if len(title_parts) < 2:
|
||||
return None
|
||||
|
||||
course_name = title_parts[0]
|
||||
exam_time = title_parts[1] if len(title_parts) > 1 else ""
|
||||
|
||||
# 拼接地点信息
|
||||
location_parts = title_parts[2:] if len(title_parts) > 2 else []
|
||||
exam_location = " ".join([part for part in location_parts if part.strip()])
|
||||
|
||||
# 添加座位号到备注
|
||||
note = ""
|
||||
for seat in seat_infos:
|
||||
if seat.course_name == course_name:
|
||||
note = f"座位号: {seat.seat_number}"
|
||||
note = note.removesuffix("准考证号:")
|
||||
break
|
||||
|
||||
return UnifiedExamInfo(
|
||||
course_name=course_name,
|
||||
exam_date=exam.start,
|
||||
exam_time=exam_time,
|
||||
exam_location=exam_location,
|
||||
exam_type="校统考",
|
||||
note=note,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
conn.logger.error(f"转换校统考数据异常: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_other_exam_records(
|
||||
term_code: str, conn: AUFEConnection
|
||||
) -> List[OtherExamRecord]:
|
||||
"""
|
||||
获取其他考试记录
|
||||
|
||||
Args:
|
||||
term_code: 学期代码
|
||||
conn: AUFEConnection
|
||||
|
||||
Returns:
|
||||
List: 其他考试记录列表
|
||||
"""
|
||||
try:
|
||||
headers = {
|
||||
**conn.client.headers,
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
}
|
||||
|
||||
data = {"zxjxjhh": term_code, "tab": "0", "pageNum": "1", "pageSize": "30"}
|
||||
|
||||
response = await conn.client.post(
|
||||
url=JWCConfig().to_full_url(ENDPOINTS["other_exam_record"]),
|
||||
headers=headers,
|
||||
data=data,
|
||||
follow_redirects=True,
|
||||
timeout=conn.timeout,
|
||||
)
|
||||
valid = OtherExamResponse.model_validate_json(response.text)
|
||||
if valid.records:
|
||||
conn.logger.info(f"获取其他考试信息成功,共 {len(valid.records)} 条记录")
|
||||
return valid.records
|
||||
else:
|
||||
conn.logger.warning("获取其他考试信息成功,但无记录")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取其他考试信息出现如下异常: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def convert_other_exam_to_unified(
|
||||
record: OtherExamRecord, conn: AUFEConnection
|
||||
) -> Optional[UnifiedExamInfo]:
|
||||
"""
|
||||
将其他考试记录转换为统一格式
|
||||
|
||||
Args:
|
||||
record: 其他考试记录
|
||||
|
||||
Returns:
|
||||
Optional[UnifiedExamInfo]: 统一格式的考试信息
|
||||
"""
|
||||
try:
|
||||
return UnifiedExamInfo(
|
||||
course_name=record.course_name,
|
||||
exam_date=record.exam_date,
|
||||
exam_time=record.exam_time,
|
||||
exam_location=record.exam_location,
|
||||
exam_type="其他考试",
|
||||
note=record.note,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
conn.logger.error(f"转换其他考试数据异常: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_unified_exam_info(
|
||||
conn: AUFEConnection,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
term_code: str = "2024-2025-2-1",
|
||||
) -> ExamInfoResponse:
|
||||
"""
|
||||
获取统一的考试信息,包括校统考和其他考试
|
||||
|
||||
Args:
|
||||
start_date: 开始日期 (YYYY-MM-DD)
|
||||
end_date: 结束日期 (YYYY-MM-DD)
|
||||
term_code: 学期代码,默认为当前学期
|
||||
|
||||
Returns:
|
||||
ExamInfoResponse: 统一的考试信息响应
|
||||
"""
|
||||
try:
|
||||
# 合并并转换为统一格式
|
||||
unified_exams = []
|
||||
# 获取校统考信息
|
||||
if school_exams := await fetch_school_exam_schedule(start_date, end_date, conn):
|
||||
# 获取座位号信息
|
||||
seat_info = await fetch_exam_seat_info(conn)
|
||||
# 处理校统考数据
|
||||
for exam in school_exams:
|
||||
unified_exam = convert_school_exam_to_unified(exam, seat_info, conn)
|
||||
if unified_exam:
|
||||
unified_exams.append(unified_exam)
|
||||
|
||||
# 获取其他考试信息
|
||||
other_exams = await fetch_other_exam_records(term_code, conn)
|
||||
# 处理其他考试数据
|
||||
for record in other_exams:
|
||||
unified_exam = convert_other_exam_to_unified(record, conn)
|
||||
if unified_exam:
|
||||
unified_exams.append(unified_exam)
|
||||
|
||||
# 按考试日期排序
|
||||
def _sort_key(exam: UnifiedExamInfo) -> str:
|
||||
return exam.exam_date + " " + exam.exam_time
|
||||
|
||||
unified_exams.sort(key=_sort_key)
|
||||
|
||||
return ExamInfoResponse(
|
||||
exams=unified_exams,
|
||||
total_count=len(unified_exams),
|
||||
)
|
||||
|
||||
except Exception:
|
||||
raise
|
||||
67
loveace/router/endpoint/jwc/utils/plan.py
Normal file
67
loveace/router/endpoint/jwc/utils/plan.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from loveace.router.endpoint.jwc.model.plan import (
|
||||
PlanCompletionCategory,
|
||||
PlanCompletionCourse,
|
||||
)
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
|
||||
|
||||
def populate_category_children(
|
||||
category: PlanCompletionCategory,
|
||||
category_id: str,
|
||||
nodes_by_id: dict,
|
||||
conn: AUFEConnection,
|
||||
):
|
||||
"""填充分类的子分类和课程(支持多层嵌套)"""
|
||||
try:
|
||||
children_count = 0
|
||||
subcategory_count = 0
|
||||
course_count = 0
|
||||
|
||||
for node in nodes_by_id.values():
|
||||
if node.get("pId") == category_id:
|
||||
children_count += 1
|
||||
flag_type = node.get("flagType", "")
|
||||
|
||||
if flag_type in ["001", "002"]: # 分类或子分类
|
||||
subcategory = PlanCompletionCategory.from_ztree_node(node)
|
||||
# 递归处理子项,支持多层嵌套
|
||||
populate_category_children(
|
||||
subcategory, node["id"], nodes_by_id, conn
|
||||
)
|
||||
category.subcategories.append(subcategory)
|
||||
subcategory_count += 1
|
||||
elif flag_type == "kch": # 课程
|
||||
course = PlanCompletionCourse.from_ztree_node(node)
|
||||
category.courses.append(course)
|
||||
course_count += 1
|
||||
else:
|
||||
# 处理其他类型的节点,也可能是分类
|
||||
# 根据是否有子节点来判断是分类还是课程
|
||||
has_children = any(
|
||||
n.get("pId") == node["id"] for n in nodes_by_id.values()
|
||||
)
|
||||
if has_children:
|
||||
# 有子节点,当作分类处理
|
||||
subcategory = PlanCompletionCategory.from_ztree_node(node)
|
||||
populate_category_children(
|
||||
subcategory, node["id"], nodes_by_id, conn
|
||||
)
|
||||
category.subcategories.append(subcategory)
|
||||
subcategory_count += 1
|
||||
else:
|
||||
# 无子节点,当作课程处理
|
||||
course = PlanCompletionCourse.from_ztree_node(node)
|
||||
category.courses.append(course)
|
||||
course_count += 1
|
||||
|
||||
if children_count > 0:
|
||||
conn.logger.info(
|
||||
f"分类 '{category.category_name}' (ID: {category_id}) 的子项: 总数={children_count}, 子分类={subcategory_count}, 课程={course_count}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
conn.logger.error(f"填充分类子项异常: {str(e)}")
|
||||
conn.logger.error(
|
||||
f"异常节点信息: category_id={category_id}, 错误详情: {str(e)}"
|
||||
)
|
||||
raise
|
||||
27
loveace/router/endpoint/jwc/utils/zxjxjhh_to_term_format.py
Normal file
27
loveace/router/endpoint/jwc/utils/zxjxjhh_to_term_format.py
Normal file
@@ -0,0 +1,27 @@
|
||||
def convert_zxjxjhh_to_term_format(zxjxjhh: str) -> str:
|
||||
"""
|
||||
转换学期格式
|
||||
xxxx-yyyy-1-1 -> xxxx-yyyy秋季学期
|
||||
xxxx-yyyy-2-1 -> xxxx-yyyy春季学期
|
||||
|
||||
Args:
|
||||
zxjxjhh: 学期代码,如 "2025-2026-1-1"
|
||||
|
||||
Returns:
|
||||
str: 转换后的学期名称,如 "2025-2026秋季学期"
|
||||
"""
|
||||
try:
|
||||
parts = zxjxjhh.split("-")
|
||||
if len(parts) >= 3:
|
||||
year_start = parts[0]
|
||||
year_end = parts[1]
|
||||
semester_num = parts[2]
|
||||
|
||||
if semester_num == "1":
|
||||
return f"{year_start}-{year_end}秋季学期"
|
||||
elif semester_num == "2":
|
||||
return f"{year_start}-{year_end}春季学期"
|
||||
|
||||
return zxjxjhh # 如果格式不匹配,返回原值
|
||||
except Exception:
|
||||
return zxjxjhh
|
||||
Reference in New Issue
Block a user