Files
LoveACE-EndF/loveace/router/endpoint/isim/utils/isim.py
Sibuxiangx bbc86b8330 ⚒️ 重大重构 LoveACE V2
引入了 mongodb
对数据库进行了一定程度的数据加密
性能改善
代码简化
统一错误模型和响应
使用 apifox 作为文档
2025-11-20 20:44:25 +08:00

1426 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import datetime
import hashlib
import json
import random
import re
from functools import wraps
from typing import Any, Dict, List, Optional
from bs4 import BeautifulSoup
from fastapi import Depends
from loveace.config.manager import config_manager
from loveace.router.endpoint.isim.model.isim import (
ElectricityBalance,
ElectricityUsageRecord,
PaymentRecord,
)
from loveace.router.endpoint.isim.model.room import (
BuildingInfo,
CacheBuildingData,
CacheFloorData,
CacheRoomsData,
FloorInfo,
RoomInfo,
)
from loveace.service.remote.aufe import AUFEConnection, SubClient
from loveace.service.remote.aufe.depends import get_aufe_conn
from loveace.utils.redis_client import get_redis_client
def ensure_session(func):
"""装饰器:确保在调用方法前已初始化会话"""
@wraps(func)
async def wrapper(self, *args, **kwargs):
if isinstance(self, ISIMClient):
await self._ensure_jsession()
return await func(self, *args, **kwargs)
return wrapper
class ISIMClient(SubClient):
"""ISIM系统客户端用于获取楼栋、楼层和房间信息
该客户端会自动管理会话初始化,无需手动调用 get_jsession()
"""
DEFAULT_BASE_URL = config_manager.get_settings().isim.base_url.rstrip("/")
def __init__(self, aufe_connection: AUFEConnection, auto_init: bool = True):
"""
初始化ISIM客户端
Args:
aufe_connection: AUFE连接实例
auto_init: 是否自动初始化会话默认为True推荐保持默认
"""
self.client = aufe_connection
self.config = config_manager.get_settings()
self._jsessionid: Optional[str] = None
self._session_initialized = False
self._auto_init = auto_init
self._init_lock = asyncio.Lock() # 防止并发初始化
self._jsession_bound = False # 标记是否已绑定寝室
def _generate_session_params(self) -> Dict[str, str]:
"""生成会话参数openid和sn"""
seed = self.client.userid if self.client.userid != "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}
async def _ensure_jsession(self) -> None:
"""
确保会话已初始化(内部方法)
使用锁机制防止并发初始化
"""
if self._session_initialized and self._jsessionid:
return
async with self._init_lock:
# 双重检查,避免重复初始化
if self._session_initialized and self._jsessionid:
return
if not self._auto_init:
raise RuntimeError(
"ISIM会话未初始化。请先调用 get_jsession() 或在创建实例时设置 auto_init=True"
)
success = await self.get_jsession()
if not success:
raise RuntimeError("ISIM会话初始化失败请检查网络连接或认证状态")
async def get_jsession(self) -> bool:
"""
初始化ISIM会话获取JSESSIONID
通常不需要手动调用此方法,客户端会在需要时自动初始化
Returns:
bool: 是否成功获取JSESSIONID
"""
try:
self.client.logger.info("开始初始化ISIM会话")
params = self._generate_session_params()
response = await self.client.client.get(
f"{self.DEFAULT_BASE_URL}/go",
params=params,
follow_redirects=False,
timeout=self.client.timeout,
)
# 检查是否收到302重定向响应
if response.status_code == 302:
set_cookie_header = response.headers.get("set-cookie", "")
if "JSESSIONID=" in set_cookie_header:
jsessionid_match = re.search(
r"JSESSIONID=([^;]+)", set_cookie_header
)
if jsessionid_match:
self._jsessionid = jsessionid_match.group(1)
self._session_initialized = True # 标记会话已初始化
self.client.logger.info(
f"成功获取JSESSIONID: {jsessionid_match.group(1)[:10]}***"
)
# 验证重定向位置
location = response.headers.get("location", "")
if "home" in location and "jsessionid" in location:
self.client.logger.info(
f"重定向位置正确: {location[:8]}****"
)
else:
self.client.logger.warning(f"重定向位置异常: {location}")
return True
self.client.logger.error("未能从Set-Cookie头中提取JSESSIONID")
return False
else:
self.client.logger.error(
f"期望302重定向但收到状态码: {response.status_code}"
)
if response.text:
self.client.logger.debug(f"响应内容: {response.text[:200]}...")
return False
except Exception as e:
self.client.logger.error(f"初始化ISIM会话异常: {str(e)}")
return False
@ensure_session
async def get_buildings(
self, jsessionid: Optional[str] = None
) -> List[BuildingInfo]:
"""
获取楼栋列表
Args:
jsessionid: 会话ID通常不需要提供自动使用实例中的ID
Returns:
List[BuildingInfo]: 楼栋信息列表
"""
jsessionid = jsessionid or self._jsessionid
try:
headers = {
**self.config.aufe.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": f"JSESSIONID={jsessionid}; TWFID={self.client.twf_id}",
"Referer": f"{self.DEFAULT_BASE_URL}/home;jsessionid={jsessionid}",
}
response = await self.client.client.get(
f"{self.DEFAULT_BASE_URL}/about",
headers=headers,
follow_redirects=True,
timeout=self.client.timeout,
)
if response.status_code != 200:
raise Exception(f"请求失败,状态码: {response.status_code}")
# 解析HTML页面获取楼栋信息
soup = BeautifulSoup(response.text, "html.parser")
buildings = []
scripts = soup.find_all("script")
for script in scripts:
if script.string and "pickerBuilding" in script.string:
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 = [v.strip().strip('"') for v in values_str.split(",")]
display_values = [
v.strip().strip('"') for v in display_values_str.split(",")
]
for code, name in zip(values, display_values):
if code and code != '""' and name != "请选择":
buildings.append(BuildingInfo(code=code, name=name))
break
self.client.logger.info(f"成功获取{len(buildings)}个楼栋信息")
return buildings
except Exception as e:
self.client.logger.exception(e)
self.client.logger.error(f"获取楼栋列表异常: {str(e)}")
return []
@ensure_session
async def get_floors(
self, building_code: str, jsessionid: Optional[str] = None
) -> List[FloorInfo]:
"""
获取指定楼栋的楼层列表
Args:
building_code: 楼栋代码
jsessionid: 会话ID通常不需要提供自动使用实例中的ID
Returns:
List[FloorInfo]: 楼层信息列表
"""
jsessionid = jsessionid or self._jsessionid
try:
self.client.logger.info(f"开始获取楼层列表,楼栋代码: {building_code}")
headers = {
**self.config.aufe.default_headers,
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "zh-CN,zh;q=0.9",
"Connection": "keep-alive",
"X-Requested-With": "XMLHttpRequest",
"Cookie": f"JSESSIONID={jsessionid}; TWFID={self.client.twf_id}",
"Referer": f"{self.DEFAULT_BASE_URL}/about;jsessionid={jsessionid}",
}
response = await self.client.client.get(
f"{self.DEFAULT_BASE_URL}/about/floors/{building_code}",
headers=headers,
follow_redirects=True,
timeout=self.client.timeout,
)
if response.status_code != 200:
raise Exception(f"请求失败,状态码: {response.status_code}")
# 解析响应
data_str = response.text.strip()
self.client.logger.debug(f"楼层响应原始数据: {data_str[:200]}...")
try:
json_data = response.json()
except Exception:
# 手动转换JavaScript对象字面量为JSON格式
json_str = re.sub(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*:", r'"\1":', data_str)
self.client.logger.debug(f"转换后的JSON字符串: {json_str[:200]}...")
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))
self.client.logger.info(f"成功获取{len(floors)}个楼层信息")
return floors
else:
self.client.logger.warning(f"楼层数据格式异常: {json_data}")
return []
except Exception as e:
self.client.logger.error(f"获取楼层列表异常: {str(e)}")
return []
@ensure_session
async def get_rooms(
self, floor_code: str, jsessionid: Optional[str] = None
) -> List[RoomInfo]:
"""
获取指定楼层的房间列表
Args:
floor_code: 楼层代码
jsessionid: 会话ID通常不需要提供自动使用实例中的ID
Returns:
List[RoomInfo]: 房间信息列表
"""
jsessionid = jsessionid or self._jsessionid
try:
self.client.logger.info(f"开始获取房间列表,楼层代码: {floor_code}")
headers = {
**self.config.aufe.default_headers,
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "zh-CN,zh;q=0.9",
"Connection": "keep-alive",
"X-Requested-With": "XMLHttpRequest",
"Cookie": f"JSESSIONID={jsessionid}; TWFID={self.client.twf_id}",
"Referer": f"{self.DEFAULT_BASE_URL}/about;jsessionid={jsessionid}",
}
response = await self.client.client.get(
f"{self.DEFAULT_BASE_URL}/about/rooms/{floor_code}",
headers=headers,
follow_redirects=True,
timeout=self.client.timeout,
)
if response.status_code != 200:
raise Exception(f"请求失败,状态码: {response.status_code}")
# 解析响应
data_str = response.text.strip()
self.client.logger.debug(f"房间响应原始数据: {data_str[:200]}...")
try:
json_data = response.json()
except Exception:
# 手动转换JavaScript对象字面量为JSON格式
json_str = re.sub(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*:", r'"\1":', data_str)
self.client.logger.debug(f"转换后的JSON字符串: {json_str[:200]}...")
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))
self.client.logger.info(f"成功获取{len(rooms)}个房间信息")
return rooms
else:
self.client.logger.warning(f"房间数据格式异常: {json_data}")
return []
except Exception as e:
self.client.logger.error(f"获取房间列表异常: {str(e)}")
return []
@ensure_session
async def bind_room_to_jsession(self, room_code: str) -> bool:
"""
绑定房间到当前会话
Args:
room_code: 房间代码
Returns:
bool: 是否绑定成功
"""
if self._jsession_bound:
return True # 已绑定,直接返回成功
params = self._generate_session_params()
room_id = room_code
display_text = await self.get_room_display_text(room_id)
if not display_text:
self.client.logger.error(f"未找到房间名称: {room_id}")
return False
data = {
"sn": params["sn"],
"openid": params["openid"],
"roomdm": room_id,
"room": display_text,
"mode": "u", # u表示更新绑定
}
headers = {
**self.config.aufe.default_headers,
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "zh-CN,zh;q=0.9",
"Connection": "keep-alive",
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Cookie": f"JSESSIONID={self._jsessionid}; TWFID={self.client.twf_id}",
"Referer": f"{self.DEFAULT_BASE_URL}/about;jsessionid={self._jsessionid}",
}
response = await self.client.client.post(
f"{self.DEFAULT_BASE_URL}/about/rebinding",
headers=headers,
data=data,
follow_redirects=True,
timeout=self.client.timeout,
)
if response.status_code != 200:
self.client.logger.error(
f"绑定寝室请求失败,状态码: {response.status_code}"
)
return False
self._jsession_bound = True
return True
@ensure_session
async def get_all_room_data(
self, max_retries: int = 2, retry_delay: float = 1.0
) -> CacheRoomsData:
"""
获取所有楼栋、楼层和房间的完整数据结构
支持失败节点自动重试机制:
- 第一轮并发请求所有数据
- 提取失败的楼层节点
- 单独重试失败节点
Args:
max_retries: 失败节点最大重试次数默认2次
retry_delay: 重试延迟时间默认1秒
Returns:
CacheRoomsData: 完整的房间数据结构
"""
jsessionid = self._jsessionid
# 第一步:获取所有楼栋
self.client.logger.info("开始获取所有楼栋信息")
buildings = await self.get_buildings(jsessionid)
if not buildings:
self.client.logger.error("获取楼栋列表失败")
return CacheRoomsData(
datetime=datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
data=[],
)
# 第二步:并发获取所有楼层
self.client.logger.info(f"开始并发获取 {len(buildings)} 个楼栋的楼层信息")
floor_tasks = [
self.get_floors(building.code, jsessionid) for building in buildings
]
all_floors = await asyncio.gather(*floor_tasks, return_exceptions=True)
# 处理楼层获取结果,记录失败的楼栋
failed_buildings: List[BuildingInfo] = []
valid_floors: List[List[FloorInfo]] = []
for i, floors_result in enumerate(all_floors):
if isinstance(floors_result, Exception):
self.client.logger.warning(
f"楼栋 {buildings[i].code} ({buildings[i].name}) "
f"获取楼层失败: {str(floors_result)}"
)
failed_buildings.append(buildings[i])
valid_floors.append([])
elif not floors_result or not isinstance(floors_result, list):
self.client.logger.warning(
f"楼栋 {buildings[i].code} ({buildings[i].name}) 楼层数据为空"
)
valid_floors.append([])
else:
valid_floors.append(floors_result)
# 重试失败的楼栋
if failed_buildings and max_retries > 0:
self.client.logger.info(
f"开始重试 {len(failed_buildings)} 个失败的楼栋,最大重试次数: {max_retries}"
)
for retry_count in range(1, max_retries + 1):
if not failed_buildings:
break
self.client.logger.info(
f"{retry_count} 次重试,待重试楼栋数: {len(failed_buildings)}"
)
# 延迟后重试
if retry_delay > 0:
await asyncio.sleep(retry_delay)
retry_tasks = [
self.get_floors(building.code, jsessionid)
for building in failed_buildings
]
retry_results = await asyncio.gather(
*retry_tasks, return_exceptions=True
)
# 更新成功的结果
new_failed_buildings: List[BuildingInfo] = []
for i, result in enumerate(retry_results):
building = failed_buildings[i]
building_index = next(
(
idx
for idx, b in enumerate(buildings)
if b.code == building.code
),
None,
)
if isinstance(result, Exception):
self.client.logger.warning(
f"重试 {retry_count}: 楼栋 {building.code} 仍然失败: {str(result)}"
)
new_failed_buildings.append(building)
elif not result or not isinstance(result, list):
self.client.logger.warning(
f"重试 {retry_count}: 楼栋 {building.code} 数据为空"
)
else:
self.client.logger.info(
f"重试 {retry_count}: 楼栋 {building.code} 成功获取 {len(result)} 个楼层"
)
if building_index is not None:
valid_floors[building_index] = result
failed_buildings = new_failed_buildings
# 统计最终失败的楼栋
if failed_buildings:
self.client.logger.error(
f"最终仍有 {len(failed_buildings)} 个楼栋获取失败: "
f"{[f'{b.code}({b.name})' for b in failed_buildings]}"
)
# 第三步:并发获取所有房间
self.client.logger.info("开始并发获取所有房间信息")
# 收集所有楼层代码及其所属楼栋索引
floor_info_list: List[tuple[int, int, FloorInfo]] = (
[]
) # (building_idx, floor_idx, floor)
for building_idx, floors in enumerate(valid_floors):
for floor_idx, floor in enumerate(floors):
floor_info_list.append((building_idx, floor_idx, floor))
# 并发获取房间
room_tasks = [
self.get_rooms(floor.code, jsessionid) for _, _, floor in floor_info_list
]
all_rooms_results = await asyncio.gather(*room_tasks, return_exceptions=True)
# 处理房间获取结果
failed_floors: List[tuple[int, int, FloorInfo]] = []
room_results_map: Dict[str, List[RoomInfo]] = {}
for i, rooms_result in enumerate(all_rooms_results):
building_idx, floor_idx, floor = floor_info_list[i]
if isinstance(rooms_result, Exception):
self.client.logger.warning(
f"楼层 {floor.code} ({floor.name}) 获取房间失败: {str(rooms_result)}"
)
failed_floors.append((building_idx, floor_idx, floor))
room_results_map[floor.code] = []
elif not rooms_result or not isinstance(rooms_result, list):
self.client.logger.debug(
f"楼层 {floor.code} ({floor.name}) 房间数据为空"
)
room_results_map[floor.code] = []
else:
room_results_map[floor.code] = rooms_result
# 重试失败的楼层
if failed_floors and max_retries > 0:
self.client.logger.info(
f"开始重试 {len(failed_floors)} 个失败的楼层,最大重试次数: {max_retries}"
)
for retry_count in range(1, max_retries + 1):
if not failed_floors:
break
self.client.logger.info(
f"{retry_count} 次重试,待重试楼层数: {len(failed_floors)}"
)
# 延迟后重试
if retry_delay > 0:
await asyncio.sleep(retry_delay)
retry_tasks = [
self.get_rooms(floor.code, jsessionid)
for _, _, floor in failed_floors
]
retry_results = await asyncio.gather(
*retry_tasks, return_exceptions=True
)
# 更新成功的结果
new_failed_floors: List[tuple[int, int, FloorInfo]] = []
for i, result in enumerate(retry_results):
building_idx, floor_idx, floor = failed_floors[i]
if isinstance(result, Exception):
self.client.logger.warning(
f"重试 {retry_count}: 楼层 {floor.code} 仍然失败: {str(result)}"
)
new_failed_floors.append((building_idx, floor_idx, floor))
elif not result or not isinstance(result, list):
self.client.logger.warning(
f"重试 {retry_count}: 楼层 {floor.code} 数据为空"
)
room_results_map[floor.code] = []
else:
self.client.logger.info(
f"重试 {retry_count}: 楼层 {floor.code} 成功获取 {len(result)} 个房间"
)
room_results_map[floor.code] = result
failed_floors = new_failed_floors
# 统计最终失败的楼层
if failed_floors:
self.client.logger.error(
f"最终仍有 {len(failed_floors)} 个楼层获取失败: "
f"{[f'{floor.code}({floor.name})' for _, _, floor in failed_floors]}"
)
# 第四步:构建完整数据结构
self.client.logger.info("开始构建完整数据结构")
buildings.sort(key=lambda b: b.code)
data = []
for i, building in enumerate(buildings):
floors = valid_floors[i]
if not floors:
# 该楼栋没有楼层数据,跳过
continue
floors.sort(key=lambda f: f.code)
floor_data_list = []
for floor in floors:
rooms = room_results_map.get(floor.code, [])
floor_data_list.append(
CacheFloorData(
code=floor.code,
name=floor.name,
rooms=rooms,
)
)
data.append(
CacheBuildingData(
code=building.code,
name=building.name,
floors=floor_data_list,
)
)
result = CacheRoomsData(
datetime=datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
data=data,
)
# 统计信息
total_buildings = len(result.data)
total_floors = sum(len(b.floors) for b in result.data)
total_rooms = sum(
len(floor.rooms) for building in result.data for floor in building.floors
)
self.client.logger.info(
f"数据获取完成 - 楼栋: {total_buildings}, 楼层: {total_floors}, 房间: {total_rooms}"
)
return result
async def _cache_room_data_to_hash(self) -> None:
"""
将房间数据平铺缓存到Redis Hash中过期时间为一周
Hash结构:
- key: isim:rooms:v1:data
- fields:
- meta: 元数据(更新时间、版本号)
- building:{code}: 楼栋信息 JSON
- floor:{code}: 楼层信息 JSON
- rooms:{floor_code}: 该楼层的所有房间列表 JSON
"""
try:
self.client.logger.info("开始构建Hash缓存结构")
data = await self.get_all_room_data()
redis_client = await get_redis_client()
# 构建Hash映射
hash_mapping: Dict[str, Any] = {}
# 添加元数据
meta = {
"datetime": data.datetime,
"version": "v1",
"building_count": len(data.data),
}
hash_mapping["meta"] = json.dumps(meta, ensure_ascii=False)
# 平铺楼栋、楼层、房间数据
floor_count = 0
room_count = 0
for building in data.data:
# 存储楼栋信息
building_key = f"building:{building.code}"
building_info = {
"code": building.code,
"name": building.name,
}
hash_mapping[building_key] = json.dumps(
building_info, ensure_ascii=False
)
# 存储楼层和房间信息
for floor in building.floors:
floor_count += 1
# 存储楼层信息
floor_key = f"floor:{floor.code}"
floor_info = {
"code": floor.code,
"name": floor.name,
"building_code": building.code,
}
hash_mapping[floor_key] = json.dumps(floor_info, ensure_ascii=False)
# 存储该楼层的所有房间
rooms_key = f"rooms:{floor.code}"
rooms_list = [
{"code": room.code, "name": room.name} for room in floor.rooms
]
room_count += len(rooms_list)
hash_mapping[rooms_key] = json.dumps(rooms_list, ensure_ascii=False)
# 批量写入Hash
HASH_KEY = "isim:rooms:v1:data"
await redis_client.hash_set(HASH_KEY, hash_mapping)
# 设置过期时间为一周
ONE_WEEK_SECONDS = 7 * 24 * 60 * 60
await redis_client.expire(HASH_KEY, ONE_WEEK_SECONDS)
self.client.logger.info(
f"房间数据已缓存到Redis Hash"
f"楼栋数: {len(data.data)}, "
f"楼层数: {floor_count}, "
f"房间数: {room_count}, "
f"过期时间: 7天"
)
except Exception as e:
self.client.logger.error(f"缓存房间数据到Redis Hash失败: {str(e)}")
self.client.logger.exception(e)
async def get_cached_rooms_from_hash(
self,
building_code: Optional[str] = None,
floor_code: Optional[str] = None,
) -> Optional[CacheRoomsData]:
"""
从Redis Hash中获取房间缓存数据
支持全量获取或按楼栋/楼层查询
Args:
building_code: 楼栋代码为None则获取全部
floor_code: 楼层代码为None则获取该楼栋全部楼层
Returns:
CacheRoomsData: 房间数据不存在返回None
"""
try:
redis_client = await get_redis_client()
HASH_KEY = "isim:rooms:v1:data"
# 检查Hash是否存在
if not await redis_client.exists(HASH_KEY):
self.client.logger.info("Redis Hash缓存不存在")
return None
# 获取元数据
meta_str = await redis_client.hash_get(HASH_KEY, "meta")
if not meta_str:
self.client.logger.warning("Hash中缺少元数据")
return None
meta = json.loads(meta_str)
# 根据查询条件获取数据
if building_code:
# 按楼栋或楼层查询
return await self._get_rooms_by_building(
redis_client, HASH_KEY, building_code, floor_code, meta
)
else:
# 全量查询
return await self._get_all_rooms_from_hash(redis_client, HASH_KEY, meta)
except Exception as e:
self.client.logger.error(f"从Hash获取房间缓存异常: {str(e)}")
self.client.logger.exception(e)
return None
async def _get_all_rooms_from_hash(
self,
redis_client: Any,
hash_key: str,
meta: Dict[str, Any],
) -> CacheRoomsData:
"""从Hash中获取所有房间数据"""
try:
# 获取所有Hash字段
all_data = await redis_client.hash_get_all(hash_key)
# 提取所有楼栋代码
building_codes = set()
for field in all_data.keys():
if field.startswith("building:"):
code = field.replace("building:", "")
building_codes.add(code)
# 构建完整数据结构
buildings = []
for building_code in sorted(building_codes):
building_key = f"building:{building_code}"
building_data = json.loads(all_data.get(building_key, "{}"))
if not building_data:
continue
# 查找该楼栋的所有楼层
floors = []
for field, value in all_data.items():
if field.startswith("floor:") and field.replace(
"floor:", ""
).startswith(building_code):
floor_data = json.loads(value)
floor_code = floor_data["code"]
# 获取该楼层的房间
rooms_key = f"rooms:{floor_code}"
rooms_data = json.loads(all_data.get(rooms_key, "[]"))
floors.append(
CacheFloorData(
code=floor_data["code"],
name=floor_data["name"],
rooms=[RoomInfo(**room) for room in rooms_data],
)
)
# 按楼层代码排序
floors.sort(key=lambda f: f.code)
buildings.append(
CacheBuildingData(
code=building_data["code"],
name=building_data["name"],
floors=floors,
)
)
return CacheRoomsData(
datetime=meta.get("datetime", ""),
data=buildings,
)
except Exception as e:
self.client.logger.error(f"从Hash构建完整数据结构失败: {str(e)}")
raise
async def _get_rooms_by_building(
self,
redis_client: Any,
hash_key: str,
building_code: str,
floor_code: Optional[str],
meta: Dict[str, Any],
) -> CacheRoomsData:
"""从Hash中按楼栋获取房间数据"""
try:
# 获取楼栋信息
building_key = f"building:{building_code}"
building_str = await redis_client.hash_get(hash_key, building_key)
if not building_str:
self.client.logger.warning(f"楼栋 {building_code} 不存在")
return CacheRoomsData(datetime=meta.get("datetime", ""), data=[])
building_data = json.loads(building_str)
# 获取楼层列表
floors = []
if floor_code:
# 查询特定楼层
floor_key = f"floor:{floor_code}"
floor_str = await redis_client.hash_get(hash_key, floor_key)
if floor_str:
floor_data = json.loads(floor_str)
rooms_key = f"rooms:{floor_code}"
rooms_str = await redis_client.hash_get(hash_key, rooms_key)
rooms_data = json.loads(rooms_str) if rooms_str else []
floors.append(
CacheFloorData(
code=floor_data["code"],
name=floor_data["name"],
rooms=[RoomInfo(**room) for room in rooms_data],
)
)
else:
# 查询该楼栋的所有楼层
all_data = await redis_client.hash_get_all(hash_key)
for field, value in all_data.items():
if field.startswith("floor:") and field.replace(
"floor:", ""
).startswith(building_code):
floor_data = json.loads(value)
floor_code_item = floor_data["code"]
# 获取该楼层的房间
rooms_key = f"rooms:{floor_code_item}"
rooms_str = await redis_client.hash_get(hash_key, rooms_key)
rooms_data = json.loads(rooms_str) if rooms_str else []
floors.append(
CacheFloorData(
code=floor_data["code"],
name=floor_data["name"],
rooms=[RoomInfo(**room) for room in rooms_data],
)
)
# 按楼层代码排序
floors.sort(key=lambda f: f.code)
building = CacheBuildingData(
code=building_data["code"],
name=building_data["name"],
floors=floors,
)
return CacheRoomsData(
datetime=meta.get("datetime", ""),
data=[building],
)
except Exception as e:
self.client.logger.error(f"从Hash按楼栋获取数据失败: {str(e)}")
raise
async def query_room_name(self, room_code: str) -> Optional[str]:
"""
根据房间代码查询房间名称
Args:
room_code: 房间代码
Returns:
Optional[str]: 房间名称如果未找到则返回None
"""
bulding = room_code[:2]
floor = room_code[:4]
room = room_code
rooms_data = await self.get_cached_rooms()
for building in rooms_data.data:
if building.code == bulding:
for fl in building.floors:
if fl.code == floor:
for rm in fl.rooms:
if rm.code == room:
return rm.name
return None
async def query_room_name_online(self, room_code: str) -> Optional[str]:
"""
在线根据房间代码查询房间名称
Args:
room_code: 房间代码
Returns:
Optional[str]: 房间名称如果未找到则返回None
"""
await self._ensure_jsession() # 确保会话已初始化
bulding = room_code[:2]
floor = room_code[:4]
room = room_code
jsessionid = self._jsessionid
floors = await self.get_floors(bulding, jsessionid)
for fl in floors:
if fl.code == floor:
rooms = await self.get_rooms(floor, jsessionid)
for rm in rooms:
if rm.code == room:
return rm.name
return None
async def get_room_display_text(self, room_code: str) -> Optional[str]:
"""
根据房间代码获取完整的房间显示名称
Args:
room_code: 房间代码
Returns:
Optional[str]: 完整的房间显示名称如果未找到则返回None
"""
bulding = room_code[:2]
floor = room_code[:4]
room = room_code
rooms_data = await self.get_cached_rooms()
for building in rooms_data.data:
if building.code == bulding:
for fl in building.floors:
if fl.code == floor:
for rm in fl.rooms:
if rm.code == room:
return f"{building.name}/{fl.name}/{rm.name}"
return None
async def get_cached_rooms(self) -> CacheRoomsData:
"""
从Redis获取缓存的房间数据如果缓存不存在则重新获取
使用Hash存储方案
Returns:
CacheRoomsData: 房间数据
"""
try:
# 从Hash获取缓存数据
cached_data = await self.get_cached_rooms_from_hash()
if cached_data is not None and cached_data.data:
self.client.logger.info("成功从Redis Hash获取房间缓存数据")
return cached_data
# 缓存不存在,重新获取数据
self.client.logger.info("Redis中房间缓存不存在重新获取数据")
data = await self.get_all_room_data()
# 使用Hash方案缓存数据
await self._cache_room_data_to_hash()
self.client.logger.info("房间数据已缓存到Redis Hash过期时间7天")
return data
except Exception as e:
self.client.logger.error(f"获取房间缓存异常: {str(e)}")
# 异常时返回空数据
return CacheRoomsData(datetime="", data=[])
async def refresh_expired_room_cache(self) -> None:
"""
刷新过期的房间缓存该方法已弃用改用Redis自动过期机制
为了向后兼容,该方法仍然保留但不执行任何操作
Redis会自动在7天后失效缓存
"""
self.client.logger.info("房间缓存已完全迁移到Redis使用Redis的自动过期机制")
async def force_refresh_room_cache(self) -> None:
"""
强制刷新房间缓存重新从ISIM系统获取数据并缓存到Redis Hash
"""
try:
self.client.logger.info("开始强制刷新房间缓存")
# 使用Hash方案缓存
await self._cache_room_data_to_hash()
self.client.logger.info(
"房间缓存已强制刷新并缓存到Redis Hash过期时间7天"
)
except Exception as e:
self.client.logger.error(f"强制刷新房间缓存失败: {str(e)}")
async def get_building_info(self, building_code: str) -> Optional[BuildingInfo]:
"""
从缓存中获取指定楼栋信息
Args:
building_code: 楼栋代码
Returns:
BuildingInfo: 楼栋信息不存在返回None
"""
try:
redis_client = await get_redis_client()
HASH_KEY = "isim:rooms:v1:data"
building_key = f"building:{building_code}"
building_str = await redis_client.hash_get(HASH_KEY, building_key)
if not building_str:
return None
building_data = json.loads(building_str)
return BuildingInfo(**building_data)
except Exception as e:
self.client.logger.error(f"获取楼栋信息失败: {str(e)}")
return None
async def get_floor_info(self, floor_code: str) -> Optional[FloorInfo]:
"""
从缓存中获取指定楼层信息
Args:
floor_code: 楼层代码
Returns:
FloorInfo: 楼层信息不存在返回None
"""
try:
redis_client = await get_redis_client()
HASH_KEY = "isim:rooms:v1:data"
floor_key = f"floor:{floor_code}"
floor_str = await redis_client.hash_get(HASH_KEY, floor_key)
if not floor_str:
return None
floor_data = json.loads(floor_str)
return FloorInfo(code=floor_data["code"], name=floor_data["name"])
except Exception as e:
self.client.logger.error(f"获取楼层信息失败: {str(e)}")
return None
async def get_rooms_by_floor(self, floor_code: str) -> List[RoomInfo]:
"""
从缓存中获取指定楼层的所有房间
Args:
floor_code: 楼层代码
Returns:
List[RoomInfo]: 房间列表
"""
try:
redis_client = await get_redis_client()
HASH_KEY = "isim:rooms:v1:data"
rooms_key = f"rooms:{floor_code}"
rooms_str = await redis_client.hash_get(HASH_KEY, rooms_key)
if not rooms_str:
return []
rooms_data = json.loads(rooms_str)
return [RoomInfo(**room) for room in rooms_data]
except Exception as e:
self.client.logger.error(f"获取楼层房间列表失败: {str(e)}")
return []
async def get_building_with_floors(
self, building_code: str
) -> Optional[CacheBuildingData]:
"""
从缓存中获取指定楼栋及其所有楼层和房间
Args:
building_code: 楼栋代码
Returns:
CacheBuildingData: 楼栋完整数据不存在返回None
"""
try:
data = await self.get_cached_rooms_from_hash(building_code=building_code)
if data and data.data:
return data.data[0]
return None
except Exception as e:
self.client.logger.error(f"获取楼栋完整数据失败: {str(e)}")
return None
async def query_room_info_fast(self, room_code: str) -> Optional[RoomInfo]:
"""
快速查询房间信息从Hash缓存
Args:
room_code: 房间代码010101
Returns:
RoomInfo: 房间信息不存在返回None
"""
try:
# 从房间代码提取楼层代码
if len(room_code) < 4:
return None
floor_code = room_code[:4]
rooms = await self.get_rooms_by_floor(floor_code)
# 在房间列表中查找
for room in rooms:
if room.code == room_code:
return room
return None
except Exception as e:
self.client.logger.error(f"快速查询房间信息失败: {str(e)}")
return None
async def get_headers(self) -> Dict[str, str]:
"""获取当前请求头信息"""
return {
**self.config.aufe.default_headers,
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "zh-CN,zh;q=0.9",
"Connection": "keep-alive",
"X-Requested-With": "XMLHttpRequest",
"Cookie": f"JSESSIONID={self._jsessionid}; TWFID={self.client.twf_id}",
"Referer": f"{self.DEFAULT_BASE_URL}/about;jsessionid={self._jsessionid}",
}
@ensure_session
async def get_electricity_info(self, room_code: str) -> Optional[Dict]:
"""
获取寝室电费信息,包括余额、用电记录和充值记录
Args:
room_code: 房间代码
Returns:
Optional[Dict]: 包含balance、usage_records、payments的字典失败时返回None
"""
try:
# 绑定寝室到当前会话
if not await self.bind_room_to_jsession(room_code):
self.client.logger.error(f"绑定寝室失败: {room_code}")
return None
header = await self.get_headers()
# 获取用电记录和余额信息
url_usage = f"{self.DEFAULT_BASE_URL}/use/record"
response_usage_co = self.client.client.get(
url_usage, headers=header, timeout=10000, follow_redirects=True
)
url_payment = f"{self.DEFAULT_BASE_URL}/pay/record"
response_payment_co = self.client.client.get(
url_payment, headers=header, timeout=10000, follow_redirects=True
)
response_usage, response_payment = await asyncio.gather(
response_usage_co, response_payment_co
)
if response_usage.status_code != 200:
self.client.logger.error(
f"获取寝室电费信息失败,状态码: {response_usage.status_code}"
)
return None
soup = BeautifulSoup(response_usage.text, "lxml")
# 提取余额信息
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,
)
# 获取充值记录
if response_payment.status_code != 200:
self.client.logger.error(
f"获取寝室电费充值记录失败,状态码: {response_payment.status_code}"
)
return None
soup = BeautifulSoup(response_payment.text, "lxml")
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,
)
)
self.client.logger.info(f"成功获取寝室 {room_code} 的电费信息")
return {
"balance": balance,
"usage_records": usage_records,
"payments": payment_records,
}
except Exception as e:
self.client.logger.error(f"获取寝室电费信息异常: {str(e)}")
self.client.logger.exception(e)
return None
async def aclose(self):
"""关闭客户端,释放资源"""
self.client.logger.info("正在关闭ISIM客户端")
# 目前没有额外资源需要释放
pass
async def get_isim_client(conn: AUFEConnection = Depends(get_aufe_conn)) -> ISIMClient:
"""
获取ISIM客户端实例
客户端会自动初始化会话和刷新过期的缓存,无需手动调用初始化方法
"""
if client := conn.get_subclient("isim", ISIMClient):
conn.logger.info("复用已存在的ISIM客户端实例")
return client
isim = ISIMClient(conn)
conn.logger.info("创建新的ISIM客户端实例")
conn.inject_subclient("isim", isim)
# 预先刷新过期的房间缓存(如果需要)
await isim.refresh_expired_room_cache()
return isim