新增 ISIM 电费查缴系统

This commit is contained in:
2025-09-03 13:00:40 +08:00
parent ae3693b3ea
commit b51a6371e7
26 changed files with 2148 additions and 56 deletions

View File

@@ -5,19 +5,16 @@ from provider.aufe.aac.model import (
LoveACScoreInfo,
LoveACScoreInfoResponse,
LoveACScoreListResponse,
SimpleResponse,
ErrorLoveACScoreInfo,
ErrorLoveACScoreInfoResponse,
ErrorLoveACScoreListResponse,
ErrorLoveACScoreCategory,
)
from provider.aufe.client import (
AUFEConnection,
AUFEConfig,
aufe_config_global,
activity_tracker,
retry_async,
AUFEConnectionError,
AUFELoginError,
AUFEParseError,
RetryConfig
)
@@ -123,7 +120,7 @@ class AACClient:
def _get_default_headers(self) -> dict:
"""获取默认请求头"""
return {
**AUFEConfig.DEFAULT_HEADERS,
**aufe_config_global.DEFAULT_HEADERS,
"ticket": self.system_token or "",
"sdp-app-session": self.twfid or "",
}
@@ -141,7 +138,7 @@ class AACClient:
AUFEConnectionError: 连接失败
"""
try:
headers = AUFEConfig.DEFAULT_HEADERS.copy()
headers = aufe_config_global.DEFAULT_HEADERS.copy()
response = await self.vpn_connection.requester().get(
f"{self.web_url}/", headers=headers

View File

@@ -98,7 +98,7 @@ class ErrorLoveACScoreListResponse(LoveACScoreListResponse):
code: int = -1
msg: str = "网络请求失败,已进行多次重试"
data: Optional[List[ErrorLoveACScoreCategory]] = [
data: Optional[List["ErrorLoveACScoreCategory"]] = [
ErrorLoveACScoreCategory(
ID="error", ShowNum=-1, TypeName="请求失败", TotalScore=-1.0, children=[]
)

View File

@@ -4,7 +4,7 @@ import binascii
import asyncio
import time
import random
from typing import Optional, Dict, Any, Type, Callable, Union, List
from typing import Optional, Dict, Any, Type, Callable, TypeVar, Awaitable, ParamSpec
from contextvars import ContextVar
from functools import wraps
from enum import Enum
@@ -16,7 +16,6 @@ from cryptography.hazmat.primitives import padding as symmetric_padding
from base64 import b64encode
from bs4 import BeautifulSoup
from loguru import logger
from typing import TypeVar
from pydantic import BaseModel
@@ -28,11 +27,15 @@ vpn_context_var: ContextVar[Dict[str, Any]] = ContextVar("vpn_context", default=
# 全局AUFE连接池
_aufe_connections: Dict[str, "AUFEConnection"] = {}
# 类型变量定义
T_BaseModel = TypeVar("T_BaseModel", bound=Type[BaseModel])
P = ParamSpec("P")
T = TypeVar("T")
F = TypeVar("F", bound=Callable[..., Any])
# 导入配置管理器
from config import config_manager
from config import config_manager # noqa: E402
def get_aufe_config():
"""获取AUFE配置"""
@@ -43,23 +46,23 @@ class AUFEConfig:
"""AUFE连接配置常量从配置文件读取"""
@property
def DEFAULT_TIMEOUT(self):
def DEFAULT_TIMEOUT(self) -> int:
return get_aufe_config().default_timeout
@property
def MAX_RETRIES(self):
def MAX_RETRIES(self) -> int:
return get_aufe_config().max_retries
@property
def MAX_RECONNECT_RETRIES(self):
def MAX_RECONNECT_RETRIES(self) -> int:
return get_aufe_config().max_reconnect_retries
@property
def ACTIVITY_TIMEOUT(self):
def ACTIVITY_TIMEOUT(self) -> int:
return get_aufe_config().activity_timeout
@property
def MONITOR_INTERVAL(self):
def MONITOR_INTERVAL(self) -> int:
return get_aufe_config().monitor_interval
@property
@@ -87,7 +90,7 @@ class AUFEConfig:
return get_aufe_config().default_headers
# 创建全局实例以保持向后兼容性
AUFEConfig = AUFEConfig()
aufe_config_global = AUFEConfig()
class AUFEError(Exception):
@@ -126,16 +129,16 @@ class RetryStrategy(Enum):
@dataclass
class RetryConfig:
"""重试配置"""
max_attempts: int = AUFEConfig.MAX_RETRIES
max_attempts: int = aufe_config_global.MAX_RETRIES
strategy: RetryStrategy = RetryStrategy.EXPONENTIAL_BACKOFF
base_delay: float = AUFEConfig.RETRY_BASE_DELAY
max_delay: float = AUFEConfig.RETRY_MAX_DELAY
exponential_base: float = AUFEConfig.RETRY_EXPONENTIAL_BASE
base_delay: float = aufe_config_global.RETRY_BASE_DELAY
max_delay: float = aufe_config_global.RETRY_MAX_DELAY
exponential_base: float = aufe_config_global.RETRY_EXPONENTIAL_BASE
jitter: bool = True
retry_on_exceptions: tuple = (AUFEConnectionError, AUFETimeoutError, httpx.RequestError)
def activity_tracker(func: Callable) -> Callable:
def activity_tracker(func: F) -> F:
"""活动跟踪装饰器"""
@wraps(func)
def wrapper(self, *args, **kwargs):
@@ -149,7 +152,7 @@ def activity_tracker(func: Callable) -> Callable:
self._update_activity()
return await func(self, *args, **kwargs)
return async_wrapper if asyncio.iscoroutinefunction(func) else wrapper
return async_wrapper if asyncio.iscoroutinefunction(func) else wrapper # type: ignore
def retry_async(config: Optional[RetryConfig] = None):
@@ -157,10 +160,10 @@ def retry_async(config: Optional[RetryConfig] = None):
if config is None:
config = RetryConfig()
def decorator(func: Callable) -> Callable:
def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
@wraps(func)
async def wrapper(*args, **kwargs):
last_exception = None
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
last_exception: Optional[Exception] = None
for attempt in range(config.max_attempts):
try:
@@ -181,8 +184,12 @@ def retry_async(config: Optional[RetryConfig] = None):
# 非重试异常直接抛出
raise e
# 如果所有重试都失败,抛出最后一个异常
if last_exception:
raise last_exception
else:
# 这种情况理论上不应该发生,但为了类型检查添加
raise RuntimeError("未知错误:重试失败但没有异常")
return wrapper
return decorator
@@ -248,7 +255,7 @@ class AUFEConnection:
self,
server: str,
student_id: Optional[str] = None,
timeout: float = AUFEConfig.DEFAULT_TIMEOUT,
timeout: float = aufe_config_global.DEFAULT_TIMEOUT,
retry_config: Optional[RetryConfig] = None
) -> None:
"""
@@ -281,8 +288,8 @@ class AUFEConnection:
self._cache_ttl: float = 300 # 5分钟缓存
# 大学登录相关属性
self.uaap_base_url = AUFEConfig.UAAP_BASE_URL
self.uaap_login_url = AUFEConfig.UAAP_LOGIN_URL
self.uaap_base_url = aufe_config_global.UAAP_BASE_URL
self.uaap_login_url = aufe_config_global.UAAP_LOGIN_URL
self.uaap_cookies: Optional[Dict[str, str]] = None
self._uaap_logged_in: bool = False
@@ -299,7 +306,7 @@ class AUFEConnection:
return httpx.AsyncClient(
verify=False,
timeout=self.timeout,
headers=AUFEConfig.DEFAULT_HEADERS.copy()
headers=aufe_config_global.DEFAULT_HEADERS.copy()
)
def _update_activity(self) -> None:
@@ -315,10 +322,10 @@ class AUFEConnection:
"""监控自动关闭和健康检查"""
try:
while not self._is_closed:
await asyncio.sleep(AUFEConfig.MONITOR_INTERVAL)
await asyncio.sleep(aufe_config_global.MONITOR_INTERVAL)
# 检查不活动超时
if time.time() - self.last_activity > AUFEConfig.ACTIVITY_TIMEOUT:
if time.time() - self.last_activity > aufe_config_global.ACTIVITY_TIMEOUT:
logger.info(f"由于不活动,自动关闭学生 {self.student_id} 的VPN连接")
await self.close()
break
@@ -578,7 +585,7 @@ class AUFEConnection:
AUFEConnectionError: 连接失败
"""
headers = AUFEConfig.DEFAULT_HEADERS.copy()
headers = aufe_config_global.DEFAULT_HEADERS.copy()
try:
# 步骤1: 获取登录页面以检索必要的令牌
@@ -714,7 +721,7 @@ class AUFEConnection:
"""
self._update_activity()
headers = AUFEConfig.DEFAULT_HEADERS.copy()
headers = aufe_config_global.DEFAULT_HEADERS.copy()
cookies = self.uaap_cookies if use_uaap_cookies else None
@@ -748,7 +755,7 @@ class AUFEConnection:
"""
logger.info(f"跟踪重定向到: {redirect_url}")
headers = AUFEConfig.DEFAULT_HEADERS.copy()
headers = aufe_config_global.DEFAULT_HEADERS.copy()
try:
response = await self.session.get(
@@ -1054,7 +1061,7 @@ class AUFEConnection:
"""
return (
not self._is_closed
and (time.time() - self.last_activity < AUFEConfig.ACTIVITY_TIMEOUT)
and (time.time() - self.last_activity < aufe_config_global.ACTIVITY_TIMEOUT)
and self._health.is_healthy
)
@@ -1078,7 +1085,7 @@ class AUFEConnection:
cls,
server: str,
student_id: str,
timeout: float = AUFEConfig.DEFAULT_TIMEOUT,
timeout: float = aufe_config_global.DEFAULT_TIMEOUT,
retry_config: Optional[RetryConfig] = None
) -> "AUFEConnection":
"""

View File

@@ -0,0 +1,877 @@
import re
import hashlib
import random
from typing import List, Optional, Dict
from loguru import logger
from provider.aufe.isim.model import (
BuildingInfo,
FloorInfo,
RoomInfo,
RoomBindingInfo,
ElectricityBalance,
ElectricityUsageRecord,
ElectricityInfo,
PaymentRecord,
PaymentInfo,
ErrorElectricityInfo,
ErrorPaymentInfo,
UnboundRoomElectricityInfo,
UnboundRoomPaymentInfo,
)
from provider.aufe.client import (
AUFEConnection,
aufe_config_global,
activity_tracker,
retry_async,
AUFEConnectionError,
RetryConfig
)
from bs4 import BeautifulSoup
class ISIMConfig:
"""ISIM后勤电费系统配置常量"""
DEFAULT_BASE_URL = "http://hqkd-aufe-edu-cn.vpn2.aufe.edu.cn/"
# 各类请求的相对路径
ENDPOINTS = {
"init_session": "/go",
"about_page": "/about",
"floors_api": "/about/floors/",
"rooms_api": "/about/rooms/",
"rebinding_api": "/about/rebinding",
"usage_records": "/use/record",
"payment_records": "/pay/record",
}
class ISIMClient:
"""ISIM后勤电费系统客户端"""
def __init__(
self,
vpn_connection: AUFEConnection,
base_url: str = ISIMConfig.DEFAULT_BASE_URL,
retry_config: Optional[RetryConfig] = None
):
"""
初始化ISIM系统客户端
Args:
vpn_connection: VPN连接实例
base_url: ISIM系统基础URL
retry_config: 重试配置
"""
self.vpn_connection = vpn_connection
self.base_url = base_url.rstrip("/")
self.retry_config = retry_config or RetryConfig()
self.session_cookie = None
# 从VPN连接获取用户ID和twfid
self.user_id = getattr(vpn_connection, 'student_id', 'unknown')
self.twfid = vpn_connection.get_twfid()
logger.info(f"ISIM系统客户端初始化: base_url={self.base_url}, user_id={self.user_id}, twfid={'***' + self.twfid[-4:] if self.twfid else 'None'}")
# 验证twfid是否可用
if not self.twfid:
logger.warning("警告: 未获取到twfidVPN访问可能会失败")
def is_session_valid(self) -> bool:
"""
检查ISIM会话是否仍然有效
依赖于AUFE连接状态
Returns:
bool: 会话是否有效
"""
# 检查AUFE连接状态
if not (self.vpn_connection.is_active()):
logger.info(f"AUFE连接已断开清理ISIM会话: user_id={self.user_id}")
self._cleanup_session()
return False
if not self.session_cookie:
return self.init_session()
return True
def _cleanup_session(self) -> None:
"""
清理ISIM会话数据
"""
if self.session_cookie:
logger.info(f"清理ISIM会话: user_id={self.user_id}, session={self.session_cookie[:8]}...")
self.session_cookie = None
# 从缓存中移除自己
self._remove_from_cache()
def _remove_from_cache(self) -> None:
"""
从客户端缓存中移除自己
"""
try:
from provider.aufe.isim.depends import _isim_clients
if self.user_id in _isim_clients:
del _isim_clients[self.user_id]
logger.info(f"从缓存中移除ISIM客户端: user_id={self.user_id}")
except Exception as e:
logger.error(f"移除ISIM客户端缓存失败: {str(e)}")
def _get_default_headers(self) -> dict:
"""获取默认请求头"""
return aufe_config_global.DEFAULT_HEADERS.copy()
def _get_isim_headers(self, additional_headers: Optional[Dict[str, str]] = None) -> dict:
"""获取ISIM系统专用请求头"""
headers = {
**self._get_default_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",
"Accept-Language": "zh-CN,zh;q=0.9",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
}
# 处理Cookie确保包含twfid
if additional_headers:
# 如果有额外的headers先合并
headers.update(additional_headers)
# 特殊处理Cookie确保包含twfid
if "Cookie" in headers and self.twfid:
existing_cookie = headers["Cookie"]
# 检查是否已包含TWFID
if "TWFID=" not in existing_cookie.upper():
headers["Cookie"] = f"{existing_cookie}; TWFID={self.twfid}"
elif "Cookie" not in headers and self.twfid:
headers["Cookie"] = f"TWFID={self.twfid}"
elif self.twfid:
# 如果没有额外headers但有twfid直接设置
headers["Cookie"] = f"TWFID={self.twfid}"
return headers
def _generate_session_params(self) -> Dict[str, str]:
"""生成会话参数openid和sn"""
# 使用学号作为种子生成随机数
seed = self.user_id if self.user_id != 'unknown' else 'default'
# 生成openid - 基于学号的哈希值
openid_hash = hashlib.md5(f"{seed}_openid".encode()).hexdigest()
openid = openid_hash[:15] + str(random.randint(100, 999))
# 生成sn - 简单使用固定值
sn = "sn"
return {"openid": openid, "sn": sn}
@activity_tracker
@retry_async()
async def init_session(self) -> bool:
"""
初始化ISIM会话获取JSESSIONID
Returns:
bool: 是否成功获取会话
"""
try:
logger.info("开始初始化ISIM会话")
params = self._generate_session_params()
# 初始化会话时只使用基本的VPN头信息不添加额外的Cookie
headers = self._get_default_headers()
logger.info(f"初始化会话请求头: {headers}")
response = await self.vpn_connection.requester().get(
f"{self.base_url}/go",
params=params,
headers=headers,
follow_redirects=False # 不自动跟随重定向我们需要获取Set-Cookie
)
# 检查是否收到302重定向响应
if response.status_code == 302:
# 从Set-Cookie头中提取JSESSIONID
set_cookie_header = response.headers.get('set-cookie', '')
if 'JSESSIONID=' in set_cookie_header:
# 提取JSESSIONID值
jsessionid_match = re.search(r'JSESSIONID=([^;]+)', set_cookie_header)
if jsessionid_match:
self.session_cookie = jsessionid_match.group(1)
logger.info(f"成功获取JSESSIONID: {self.session_cookie[:8]}...")
# 验证重定向位置是否正确
location = response.headers.get('location', '')
if 'home' in location and 'jsessionid' in location:
logger.info(f"重定向位置正确: {location}")
return True
else:
logger.warning(f"重定向位置异常: {location}")
return True # 仍然返回True因为已获取到JSESSIONID
logger.error("未能从Set-Cookie头中提取JSESSIONID")
return False
else:
logger.error(f"期望302重定向但收到状态码: {response.status_code}")
# 检查响应内容,可能包含错误信息
if response.text:
logger.debug(f"响应内容: {response.text[:200]}...")
return False
except Exception as e:
logger.error(f"初始化ISIM会话异常: {str(e)}")
return False
@activity_tracker
@retry_async()
async def get_buildings(self) -> List[BuildingInfo]:
"""
获取楼栋列表
Returns:
List[BuildingInfo]: 楼栋信息列表
"""
try:
logger.info("开始获取楼栋列表")
# 检查AUFE连接状态如果断开则清理会话
if not self.is_session_valid():
logger.warning("AUFE连接已断开或会话无效尝试重新初始化")
# 确保会话已初始化
if not self.session_cookie:
if not await self.init_session():
return []
headers = self._get_isim_headers({
"Cookie": f"JSESSIONID={self.session_cookie}",
"Referer": f"{self.base_url}/home;jsessionid={self.session_cookie}",
})
logger.info(f"获取楼栋列表请求头: {headers}")
response = await self.vpn_connection.requester().get(
f"{self.base_url}/about",
headers=headers,
follow_redirects=True
)
if response.status_code != 200:
raise AUFEConnectionError(f"获取楼栋信息失败,状态码: {response.status_code}")
# 解析HTML页面获取楼栋信息
soup = BeautifulSoup(response.text, 'html.parser')
# 查找JavaScript中的楼栋数据
buildings = []
scripts = soup.find_all('script')
for script in scripts:
if script.string and 'pickerBuilding' in script.string:
# 提取values和displayValues
values_match = re.search(r'values:\s*\[(.*?)\]', script.string)
display_values_match = re.search(r'displayValues:\s*\[(.*?)\]', script.string)
if values_match and display_values_match:
values_str = values_match.group(1)
display_values_str = display_values_match.group(1)
# 解析values
values = [v.strip().strip('"') for v in values_str.split(',')]
display_values = [v.strip().strip('"') for v in display_values_str.split(',')]
# 过滤掉空值和"请选择"
for i, (code, name) in enumerate(zip(values, display_values)):
if code and code != '""' and name != "请选择":
buildings.append(BuildingInfo(code=code, name=name))
break
logger.info(f"成功获取{len(buildings)}个楼栋信息")
return buildings
except Exception as e:
logger.error(f"获取楼栋列表异常: {str(e)}")
return []
@activity_tracker
@retry_async()
async def get_floors(self, building_code: str) -> List[FloorInfo]:
"""
获取指定楼栋的楼层列表
Args:
building_code: 楼栋代码
Returns:
List[FloorInfo]: 楼层信息列表
"""
try:
logger.info(f"开始获取楼层列表,楼栋代码: {building_code}")
# 检查AUFE连接状态
if not self.is_session_valid():
logger.warning("AUFE连接已断开或会话无效尝试重新初始化")
if not self.session_cookie:
if not await self.init_session():
return []
headers = self._get_isim_headers({
"Cookie": f"JSESSIONID={self.session_cookie}",
"Referer": f"{self.base_url}/about",
"Accept": "*/*",
"X-Requested-With": "XMLHttpRequest",
})
response = await self.vpn_connection.requester().get(
f"{self.base_url}/about/floors/{building_code}",
headers=headers,
follow_redirects=True
)
if response.status_code != 200:
raise AUFEConnectionError(f"获取楼层信息失败,状态码: {response.status_code}")
# 解析响应可能是JavaScript对象字面量格式
try:
data_str = response.text.strip()
logger.debug(f"楼层响应原始数据: {data_str[:200]}...")
# 先尝试标准JSON解析
try:
json_data = response.json()
except Exception:
# 如果JSON解析失败手动转换JavaScript对象字面量为JSON格式
# 将属性名添加双引号
import re
json_str = re.sub(r'([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r'"\1":', data_str)
logger.debug(f"转换后的JSON字符串: {json_str[:200]}...")
import json
json_data = json.loads(json_str)
floors = []
if isinstance(json_data, list) and len(json_data) > 0:
floor_data = json_data[0]
floor_codes = floor_data.get('floordm', [])
floor_names = floor_data.get('floorname', [])
# 跳过第一个空值("请选择"
for code, name in zip(floor_codes[1:], floor_names[1:]):
if code and name and name != "请选择":
floors.append(FloorInfo(code=code, name=name))
logger.info(f"成功获取{len(floors)}个楼层信息")
return floors
else:
logger.warning(f"楼层数据格式异常: {json_data}")
return []
except Exception as parse_error:
logger.error(f"解析楼层数据异常: {str(parse_error)}")
logger.error(f"响应内容: {response.text[:500]}")
return []
except Exception as e:
logger.error(f"获取楼层列表异常: {str(e)}")
return []
@activity_tracker
@retry_async()
async def get_rooms(self, floor_code: str) -> List[RoomInfo]:
"""
获取指定楼层的房间列表
Args:
floor_code: 楼层代码
Returns:
List[RoomInfo]: 房间信息列表
"""
try:
logger.info(f"开始获取房间列表,楼层代码: {floor_code}")
# 检查AUFE连接状态
if not self.is_session_valid():
logger.warning("AUFE连接已断开或会话无效尝试重新初始化")
if not self.session_cookie:
if not await self.init_session():
return []
headers = self._get_isim_headers({
"Cookie": f"JSESSIONID={self.session_cookie}",
"Referer": f"{self.base_url}/about",
"Accept": "*/*",
"X-Requested-With": "XMLHttpRequest",
})
response = await self.vpn_connection.requester().get(
f"{self.base_url}/about/rooms/{floor_code}",
headers=headers,
follow_redirects=True
)
if response.status_code != 200:
raise AUFEConnectionError(f"获取房间信息失败,状态码: {response.status_code}")
# 解析响应可能是JavaScript对象字面量格式
try:
data_str = response.text.strip()
logger.debug(f"房间响应原始数据: {data_str[:200]}...")
# 先尝试标准JSON解析
try:
json_data = response.json()
except Exception:
# 如果JSON解析失败手动转换JavaScript对象字面量为JSON格式
# 将属性名添加双引号
import re
json_str = re.sub(r'([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r'"\1":', data_str)
logger.debug(f"转换后的JSON字符串: {json_str[:200]}...")
import json
json_data = json.loads(json_str)
rooms = []
if isinstance(json_data, list) and len(json_data) > 0:
room_data = json_data[0]
room_codes = room_data.get('roomdm', [])
room_names = room_data.get('roomname', [])
# 跳过第一个空值("请选择"
for code, name in zip(room_codes[1:], room_names[1:]):
if code and name and name != "请选择":
rooms.append(RoomInfo(code=code, name=name))
logger.info(f"成功获取{len(rooms)}个房间信息")
return rooms
else:
logger.warning(f"房间数据格式异常: {json_data}")
return []
except Exception as parse_error:
logger.error(f"解析房间数据异常: {str(parse_error)}")
logger.error(f"响应内容: {response.text[:500]}")
return []
except Exception as e:
logger.error(f"获取房间列表异常: {str(e)}")
return []
@activity_tracker
@retry_async()
async def bind_room(self, building_code: str, floor_code: str, room_code: str) -> Optional[RoomBindingInfo]:
"""
绑定房间
Args:
building_code: 楼栋代码
floor_code: 楼层代码
room_code: 房间代码
Returns:
Optional[RoomBindingInfo]: 绑定结果信息
"""
try:
logger.info(f"开始绑定房间: {building_code}-{floor_code}-{room_code}")
if not self.session_cookie:
if not await self.init_session():
return None
# 首先获取楼栋、楼层、房间的显示名称
buildings = await self.get_buildings()
building_name = next((b.name for b in buildings if b.code == building_code), "")
floors = await self.get_floors(building_code) if building_name else []
floor_name = next((f.name for f in floors if f.code == floor_code), "")
rooms = await self.get_rooms(floor_code) if floor_name else []
room_name = next((r.name for r in rooms if r.code == room_code), "")
if not all([building_name, floor_name, room_name]):
logger.error("无法获取完整的房间信息")
return None
# room_code就是完整的房间ID无需拼接
room_id = room_code
display_text = f"{building_name}/{floor_name}/{room_name}"
# 执行绑定请求
params = self._generate_session_params()
headers = self._get_isim_headers({
"Cookie": f"JSESSIONID={self.session_cookie}",
"Content-Type": "application/x-www-form-urlencoded",
"Origin": self.base_url,
"Referer": f"{self.base_url}/about",
"X-Requested-With": "XMLHttpRequest",
})
data = {
"sn": params["sn"],
"openid": params["openid"],
"roomdm": room_id,
"room": display_text,
"mode": "u" # u表示更新绑定
}
response = await self.vpn_connection.requester().post(
f"{self.base_url}/about/rebinding",
headers=headers,
data=data,
follow_redirects=True
)
if response.status_code != 200:
raise AUFEConnectionError(f"房间绑定失败,状态码: {response.status_code}")
# 解析响应可能是JavaScript对象字面量格式
try:
data_str = response.text.strip()
logger.debug(f"房间绑定响应原始数据: {data_str}")
if data_str and len(data_str) > 0:
# 先尝试标准JSON解析
try:
json_data = response.json()
except Exception:
# 如果JSON解析失败手动转换JavaScript对象字面量为JSON格式
import re
json_str = re.sub(r'([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r'"\1":', data_str)
logger.debug(f"转换后的JSON字符串: {json_str}")
import json
json_data = json.loads(json_str)
# 解析绑定信息
if isinstance(json_data, list) and len(json_data) > 0:
binding_data = json_data[0]
binding_info = binding_data.get('bindinginfo', '')
if binding_info:
binding_result = RoomBindingInfo(
building=BuildingInfo(code=building_code, name=building_name),
floor=FloorInfo(code=floor_code, name=floor_name),
room=RoomInfo(code=room_code, name=room_name),
room_id=room_id,
display_text=binding_info
)
logger.info(f"房间绑定成功: {binding_result.display_text}")
return binding_result
logger.error(f"房间绑定响应格式异常: {data_str}")
return None
except Exception as parse_error:
logger.error(f"解析绑定结果异常: {str(parse_error)}")
return None
except Exception as e:
logger.error(f"绑定房间异常: {str(e)}")
return None
async def _check_room_binding_with_data(self, binding_record) -> bool:
"""
使用提供的绑定记录检查房间绑定状态
Args:
binding_record: 数据库中的绑定记录
Returns:
bool: 是否绑定验证成功
"""
try:
if not binding_record:
logger.warning(f"用户 {self.user_id} 没有房间绑定记录")
return False
# 首先检查AUFE连接状态如果已断开则直接返回False
if not self.vpn_connection.login_status() or not self.vpn_connection.uaap_login_status():
logger.warning(f"用户 {self.user_id} AUFE连接已断开无法验证房间绑定")
return False
# 使用真实的绑定数据进行验证
if not self.session_cookie:
if not await self.init_session():
return False
params = self._generate_session_params()
headers = self._get_isim_headers({
"Cookie": f"JSESSIONID={self.session_cookie}",
"Content-Type": "application/x-www-form-urlencoded",
"Origin": self.base_url,
"Referer": f"{self.base_url}/about",
"X-Requested-With": "XMLHttpRequest",
})
# 使用数据库中的真实房间信息进行绑定验证
data = {
"sn": params["sn"],
"openid": params["openid"],
"roomdm": binding_record.room_id, # 使用真实的房间ID
"room": f"{binding_record.building_name}/{binding_record.floor_name}/{binding_record.room_name}",
"mode": "u"
}
response = await self.vpn_connection.requester().post(
f"{self.base_url}/about/rebinding",
headers=headers,
data=data,
follow_redirects=True
)
if response.status_code == 200:
# 检查响应中是否包含有效的绑定信息
data_str = response.text.strip()
if "bindinginfo" in data_str and len(data_str) > 10:
logger.info(f"用户 {self.user_id} 房间绑定验证成功")
return True
logger.warning(f"用户 {self.user_id} 房间绑定验证失败,响应: {response.text}")
return False
except Exception as e:
logger.error(f"房间绑定验证异常: {str(e)}")
return False
@activity_tracker
@retry_async()
async def get_electricity_info(self, binding_record=None) -> ElectricityInfo:
"""
获取电费信息(余额和用电记录)
需要先绑定房间才能查询
Returns:
ElectricityInfo: 电费信息,失败时返回错误模型
"""
def _create_error_info() -> ErrorElectricityInfo:
"""创建错误电费信息"""
return ErrorElectricityInfo()
def _create_unbound_error_info() -> UnboundRoomElectricityInfo:
"""创建未绑定房间错误信息"""
return UnboundRoomElectricityInfo()
try:
logger.info("开始获取电费信息")
# 检查AUFE连接状态
if not self.is_session_valid():
logger.warning("AUFE连接已断开或会话无效无法获取电费信息")
return _create_error_info()
# 检查房间绑定状态
if not binding_record:
logger.warning(f"用户 {self.user_id} 未绑定房间,返回未绑定错误信息")
return _create_unbound_error_info()
if not self.session_cookie:
if not await self.init_session():
return _create_error_info()
headers = self._get_isim_headers({
"Cookie": f"JSESSIONID={self.session_cookie}",
"Referer": f"{self.base_url}/about",
})
response = await self.vpn_connection.requester().get(
f"{self.base_url}/use/record",
headers=headers,
follow_redirects=True
)
if response.status_code != 200:
raise AUFEConnectionError(f"获取电费信息失败,状态码: {response.status_code}")
# 解析HTML页面
soup = BeautifulSoup(response.text, 'html.parser')
# 提取余额信息
balance_items = soup.find_all('li', class_='item-content')
remaining_purchased = 0.0
remaining_subsidy = 0.0
for item in balance_items:
title_div = item.find('div', class_='item-title')
after_div = item.find('div', class_='item-after')
if title_div and after_div:
title = title_div.get_text(strip=True)
value_text = after_div.get_text(strip=True)
# 提取数值
value_match = re.search(r'([\d.]+)', value_text)
if value_match:
value = float(value_match.group(1))
if '剩余购电' in title:
remaining_purchased = value
elif '剩余补助' in title:
remaining_subsidy = value
# 提取用电记录
usage_records = []
record_items = soup.select('#divRecord ul li')
for item in record_items:
title_div = item.find('div', class_='item-title')
after_div = item.find('div', class_='item-after')
subtitle_div = item.find('div', class_='item-subtitle')
if title_div and after_div and subtitle_div:
record_time = title_div.get_text(strip=True)
usage_text = after_div.get_text(strip=True)
meter_text = subtitle_div.get_text(strip=True)
# 提取用电量
usage_match = re.search(r'([\d.]+)度', usage_text)
if usage_match:
usage_amount = float(usage_match.group(1))
# 提取电表名称
meter_match = re.search(r'电表:\s*(.+)', meter_text)
meter_name = meter_match.group(1) if meter_match else meter_text
usage_records.append(ElectricityUsageRecord(
record_time=record_time,
usage_amount=usage_amount,
meter_name=meter_name
))
balance = ElectricityBalance(
remaining_purchased=remaining_purchased,
remaining_subsidy=remaining_subsidy
)
result = ElectricityInfo(
balance=balance,
usage_records=usage_records
)
logger.info(f"成功获取电费信息: 购电余额={remaining_purchased}度, 补助余额={remaining_subsidy}度, 记录数={len(usage_records)}")
return result
except Exception as e:
logger.error(f"获取电费信息异常: {str(e)}")
return _create_error_info()
@activity_tracker
@retry_async()
async def get_payment_info(self, binding_record=None) -> PaymentInfo:
"""
获取充值信息(余额和充值记录)
需要先绑定房间才能查询
Returns:
PaymentInfo: 充值信息,失败时返回错误模型
"""
def _create_error_info() -> ErrorPaymentInfo:
"""创建错误充值信息"""
return ErrorPaymentInfo()
def _create_unbound_error_info() -> UnboundRoomPaymentInfo:
"""创建未绑定房间错误信息"""
return UnboundRoomPaymentInfo()
try:
logger.info("开始获取充值信息")
# 检查AUFE连接状态
if not self.is_session_valid():
logger.warning("AUFE连接已断开或会话无效无法获取充值信息")
return _create_error_info()
# 检查房间绑定状态
if not binding_record:
logger.warning(f"用户 {self.user_id} 未绑定房间,返回未绑定错误信息")
return _create_unbound_error_info()
if not self.session_cookie:
if not await self.init_session():
return _create_error_info()
headers = self._get_isim_headers({
"Cookie": f"JSESSIONID={self.session_cookie}",
"Referer": f"{self.base_url}/use/record",
})
response = await self.vpn_connection.requester().get(
f"{self.base_url}/pay/record",
headers=headers,
follow_redirects=True
)
if response.status_code != 200:
raise AUFEConnectionError(f"获取充值信息失败,状态码: {response.status_code}")
# 解析HTML页面
soup = BeautifulSoup(response.text, 'html.parser')
# 提取余额信息(与电费信息相同)
balance_items = soup.find_all('li', class_='item-content')
remaining_purchased = 0.0
remaining_subsidy = 0.0
for item in balance_items:
title_div = item.find('div', class_='item-title')
after_div = item.find('div', class_='item-after')
if title_div and after_div:
title = title_div.get_text(strip=True)
value_text = after_div.get_text(strip=True)
# 提取数值
value_match = re.search(r'([\d.]+)', value_text)
if value_match:
value = float(value_match.group(1))
if '剩余购电' in title:
remaining_purchased = value
elif '剩余补助' in title:
remaining_subsidy = value
# 提取充值记录
payment_records = []
record_items = soup.select('#divRecord ul li')
for item in record_items:
title_div = item.find('div', class_='item-title')
after_div = item.find('div', class_='item-after')
subtitle_div = item.find('div', class_='item-subtitle')
if title_div and after_div and subtitle_div:
payment_time = title_div.get_text(strip=True)
amount_text = after_div.get_text(strip=True)
type_text = subtitle_div.get_text(strip=True)
# 提取金额
amount_match = re.search(r'(-?[\d.]+)元', amount_text)
if amount_match:
amount = float(amount_match.group(1))
# 提取充值类型
type_match = re.search(r'类型:\s*(.+)', type_text)
payment_type = type_match.group(1) if type_match else type_text
payment_records.append(PaymentRecord(
payment_time=payment_time,
amount=amount,
payment_type=payment_type
))
balance = ElectricityBalance(
remaining_purchased=remaining_purchased,
remaining_subsidy=remaining_subsidy
)
result = PaymentInfo(
balance=balance,
payment_records=payment_records
)
logger.info(f"成功获取充值信息: 购电余额={remaining_purchased}度, 补助余额={remaining_subsidy}度, 记录数={len(payment_records)}")
return result
except Exception as e:
logger.error(f"获取充值信息异常: {str(e)}")
return _create_error_info()

View File

@@ -0,0 +1,116 @@
from fastapi import Depends, HTTPException
from database.creator import get_db_session
from provider.loveac.authme import fetch_user_by_token
from provider.aufe.isim import ISIMClient
from provider.aufe.client import AUFEConnection
from database.user import User
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Dict
from database.isim import ISIMRoomBinding
from sqlalchemy import select
# 全局ISIM客户端池
_isim_clients: Dict[str, ISIMClient] = {}
def get_cached_isim_client(user_id: str) -> ISIMClient:
"""
获取缓存的ISIM客户端
Args:
user_id: 用户ID
Returns:
ISIMClient: 缓存的ISIM客户端实例如果未找到则返回None
"""
return _isim_clients.get(user_id)
def cache_isim_client(user_id: str, client: ISIMClient) -> None:
"""
缓存ISIM客户端
Args:
user_id: 用户ID
client: ISIM客户端实例
"""
_isim_clients[user_id] = client
async def get_isim_client(
user: User = Depends(fetch_user_by_token),
session: AsyncSession = Depends(get_db_session),
) -> ISIMClient:
from loguru import logger
"""
获取ISIM客户端实例
Args:
user: 用户对象(通过认证令牌获取)
Returns:
ISIMClient: ISIM客户端实例
Raises:
HTTPException: 认证失败时抛出
"""
if not user:
raise HTTPException(status_code=400, detail="无效的令牌或用户不存在")
# 首先检查是否已有缓存的ISIM客户端
cached_client = get_cached_isim_client(user.userid)
if cached_client:
# 检查缓存的客户端是否仍然有效
try:
if cached_client.is_session_valid():
from loguru import logger
logger.info(f"复用缓存的ISIM客户端: user_id={user.userid}")
return cached_client
except Exception as e:
from loguru import logger
logger.warning(f"缓存的ISIM客户端无效将重新创建: {str(e)}")
# 创建或获取VPN连接
aufe = AUFEConnection.create_or_get_connection("vpn.aufe.edu.cn", user.userid)
# 检查VPN登录状态
if not aufe.login_status():
userid = user.userid
easyconnect_password = user.easyconnect_password
if not await aufe.login(userid, easyconnect_password):
raise HTTPException(
status_code=400,
detail="VPN登录失败请检查用户名和密码",
)
# 检查UAAP登录状态
if not aufe.uaap_login_status():
userid = user.userid
password = user.password
if not await aufe.uaap_login(userid, password):
raise HTTPException(
status_code=400,
detail="大学登录失败,请检查用户名和密码",
)
# 创建新的ISIM客户端
isim_client = ISIMClient(aufe)
result_query = await session.execute(
select(ISIMRoomBinding).where(ISIMRoomBinding.userid == user.userid)
)
binding_record = result_query.scalars().first()
if binding_record:
logger.info(f"找到用户({user.userid})绑定记录,进行启动再绑定")
await isim_client.bind_room(
building_code=binding_record.building_code,
floor_code=binding_record.floor_code,
room_code=binding_record.room_code,
)
# 缓存客户端
cache_isim_client(user.userid, isim_client)
logger.info(f"创建并缓存新的ISIM客户端: user_id={user.userid}")
return isim_client

169
provider/aufe/isim/model.py Normal file
View File

@@ -0,0 +1,169 @@
from typing import List, Optional
from pydantic import BaseModel, Field
# ==================== 基础数据模型 ====================
class BuildingInfo(BaseModel):
"""楼栋信息"""
code: str = Field(..., description="楼栋代码")
name: str = Field(..., description="楼栋名称")
class FloorInfo(BaseModel):
"""楼层信息"""
code: str = Field(..., description="楼层代码")
name: str = Field(..., description="楼层名称")
class RoomInfo(BaseModel):
"""房间信息"""
code: str = Field(..., description="房间代码")
name: str = Field(..., description="房间名称")
class RoomBindingInfo(BaseModel):
"""房间绑定信息"""
building: BuildingInfo
floor: FloorInfo
room: RoomInfo
room_id: str = Field(..., description="完整房间ID")
display_text: str = Field(..., description="显示文本北苑11号学生公寓/11-6层/11-627")
# ==================== 电费相关模型 ====================
class ElectricityBalance(BaseModel):
"""电费余额信息"""
remaining_purchased: float = Field(..., description="剩余购电(度)")
remaining_subsidy: float = Field(..., description="剩余补助(度)")
class ElectricityUsageRecord(BaseModel):
"""用电记录"""
record_time: str = Field(..., description="记录时间2025-08-29 00:04:58")
usage_amount: float = Field(..., description="用电量(度)")
meter_name: str = Field(..., description="电表名称1-101 或 1-101空调")
class ElectricityInfo(BaseModel):
"""电费信息汇总"""
balance: ElectricityBalance
usage_records: List[ElectricityUsageRecord]
# ==================== 充值相关模型 ====================
class PaymentRecord(BaseModel):
"""充值记录"""
payment_time: str = Field(..., description="充值时间2025-02-21 11:30:08")
amount: float = Field(..., description="充值金额(元)")
payment_type: str = Field(..., description="充值类型,如:下发补助、一卡通充值")
class PaymentInfo(BaseModel):
"""充值信息汇总"""
balance: ElectricityBalance
payment_records: List[PaymentRecord]
# ==================== API响应模型 ====================
class ISIMResponse(BaseModel):
"""ISIM系统基础响应模型"""
code: int = Field(..., description="响应代码0表示成功")
message: str = Field(..., description="响应消息")
@classmethod
def success(cls, message: str = "操作成功", **kwargs):
"""创建成功响应"""
return cls(code=0, message=message, **kwargs)
@classmethod
def error(cls, message: str, code: int = 1, **kwargs):
"""创建错误响应"""
return cls(code=code, message=message, **kwargs)
class BuildingListResponse(ISIMResponse):
"""楼栋列表响应"""
data: List[BuildingInfo] = Field(default_factory=list)
class FloorListResponse(ISIMResponse):
"""楼层列表响应"""
data: List[FloorInfo] = Field(default_factory=list)
class RoomListResponse(ISIMResponse):
"""房间列表响应"""
data: List[RoomInfo] = Field(default_factory=list)
class RoomBindingResponse(ISIMResponse):
"""房间绑定响应"""
data: Optional[RoomBindingInfo] = None
class ElectricityInfoResponse(ISIMResponse):
"""电费信息响应"""
data: Optional[ElectricityInfo] = None
class PaymentInfoResponse(ISIMResponse):
"""充值信息响应"""
data: Optional[PaymentInfo] = None
# ==================== 请求模型 ====================
class SetBuildingRequest(BaseModel):
"""设置楼栋请求"""
building_code: str = Field(..., description="楼栋代码")
class SetFloorRequest(BaseModel):
"""设置楼层请求"""
floor_code: str = Field(..., description="楼层代码")
class SetRoomRequest(BaseModel):
"""设置房间请求"""
building_code: str = Field(..., description="楼栋代码")
floor_code: str = Field(..., description="楼层代码")
room_code: str = Field(..., description="房间代码")
# ==================== 错误模型 ====================
class ErrorRoomBinding(BaseModel):
"""错误房间绑定信息"""
building: BuildingInfo = BuildingInfo(code="", name="请求失败")
floor: FloorInfo = FloorInfo(code="", name="")
room: RoomInfo = RoomInfo(code="", name="")
room_id: str = ""
display_text: str = "获取房间信息失败,请稍后重试"
class ErrorElectricityInfo(BaseModel):
"""错误电费信息"""
balance: ElectricityBalance = ElectricityBalance(remaining_purchased=-1.0, remaining_subsidy=-1.0)
usage_records: List[ElectricityUsageRecord] = []
class ErrorPaymentInfo(BaseModel):
"""错误充值信息"""
balance: ElectricityBalance = ElectricityBalance(remaining_purchased=-1.0, remaining_subsidy=-1.0)
payment_records: List[PaymentRecord] = []
class UnboundRoomElectricityInfo(BaseModel):
"""未绑定房间的电费信息错误"""
balance: ElectricityBalance = ElectricityBalance(remaining_purchased=-2.0, remaining_subsidy=-2.0)
usage_records: List[ElectricityUsageRecord] = []
class UnboundRoomPaymentInfo(BaseModel):
"""未绑定房间的充值信息错误"""
balance: ElectricityBalance = ElectricityBalance(remaining_purchased=-2.0, remaining_subsidy=-2.0)
payment_records: List[PaymentRecord] = []

View File

@@ -22,11 +22,10 @@ from provider.aufe.jwc.model import (
)
from provider.aufe.client import (
AUFEConnection,
AUFEConfig,
aufe_config_global,
activity_tracker,
retry_async,
AUFEConnectionError,
AUFELoginError,
AUFEParseError,
RetryConfig
)
@@ -82,7 +81,7 @@ class JWCClient:
def _get_default_headers(self) -> dict:
"""获取默认请求头"""
return AUFEConfig.DEFAULT_HEADERS.copy()
return aufe_config_global.DEFAULT_HEADERS.copy()
def _get_endpoint_url(self, endpoint: str) -> str:
"""获取端点完整URL"""
@@ -238,12 +237,40 @@ class JWCClient:
grade="",
)
def _convert_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
try:
logger.info("开始获取培养方案信息")
headers = self._get_default_headers()
# 使用重试机制
# 使用重试机制获取培养方案基本信息
plan_response = await self.vpn_connection.model_request(
model=TrainingPlanResponseWrapper,
url=f"{self.base_url}/main/showPyfaInfo?sf_request_type=ajax",
@@ -272,13 +299,46 @@ class JWCClient:
major_match = re.search(r"\d{4}级(.+?)本科", plan_name)
major_name = major_match.group(1) if major_match else ""
# 获取学术信息来补全学期和课程数量信息
term_name = ""
course_count = 0
try:
# 调用学术信息接口获取当前学期和课程数量
academic_response = await self.vpn_connection.requester().post(
f"{self.base_url}/main/academicInfo?sf_request_type=ajax",
headers=headers,
data={"flag": ""},
follow_redirects=True,
)
if academic_response.status_code == 200:
academic_data = academic_response.json()
if academic_data and isinstance(academic_data, list) and len(academic_data) > 0:
academic_item = academic_data[0]
# 获取学期代码并转换格式
zxjxjhh = academic_item.get("zxjxjhh", "")
if zxjxjhh:
term_name = _convert_term_format(zxjxjhh)
logger.info(f"从学术信息获取学期: {zxjxjhh} -> {term_name}")
# 获取课程数量
course_count = academic_item.get("courseNum", 0)
logger.info(f"从学术信息获取课程数量: {course_count}")
except Exception as e:
logger.warning(f"获取学术信息补全培养方案失败: {str(e)}")
# 使用默认值
term_name = "当前学期"
# 转换为TrainingPlanInfo格式返回
return TrainingPlanInfo(
pyfa=plan_name,
major=major_name,
grade=grade,
term="2024-2025春季学期", # 从学术信息获取更准确
courseCount=0, # 默认值,需要从其他接口获取
term=term_name,
courseCount=course_count,
)
except (AUFEConnectionError, AUFEParseError) as e:

View File

@@ -1,4 +1,3 @@
import json
import uuid
from fastapi import Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
@@ -7,7 +6,6 @@ from database.user import User, AuthME
from sqlalchemy import select, desc
from pydantic import BaseModel
from loguru import logger
from typing import Optional
class AuthmeRequest(BaseModel):