zzupy.ecard 源代码

import base64
import json
import threading
import time
import warnings
from urllib.parse import urlparse, parse_qs

import gmalg
from loguru import logger
from typing_extensions import Tuple

from zzupy.exception import DefaultRoomException, ECardTokenException
from zzupy.utils import sm4_decrypt_ecb, check_permission, sync_wrapper


[文档] class eCard: def __init__(self, parent): """ 初始化 eCard 实例 :param parent: 父对象 """ self._parent = parent self._eCardAccessToken: str = "" self._eCardRefreshToken: str = "" self._JSessionID: str = "" self._tid: str = "" self._orgId: str = "" self._timer = None # 不再自动启动token刷新定时器,由login方法负责启动
[文档] async def init_async(self): """ 异步初始化 eCard 实例 """ self._JSessionID, self._tid, self._orgId = await self._get_jsession_id() ( self._eCardAccessToken, self._eCardRefreshToken, ) = await self._get_ecard_access_token() return self
def _start_token_refresh_timer(self): """ 启动定时器,定时刷新 token """ import asyncio # 检查当前是否在事件循环中运行 try: loop = asyncio.get_running_loop() # 如果能获取到当前运行的事件循环,说明我们在异步环境中 # 使用异步方式初始化token asyncio.create_task(self._start_token_refresh_async()) return # 在异步环境中,定时器由_start_token_refresh_async启动 except RuntimeError: # 如果没有运行中的事件循环,说明我们在同步环境中 # 创建一个新的事件循环来运行异步函数 loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: # 运行异步函数并获取结果 self._JSessionID, self._tid, self._orgId = loop.run_until_complete( self._get_jsession_id() ) self._eCardAccessToken, self._eCardRefreshToken = ( loop.run_until_complete(self._get_ecard_access_token()) ) finally: # 关闭事件循环 loop.close() # 每 45 分钟(2700 秒)执行一次 self._timer = threading.Timer(2700, self._start_token_refresh_timer) self._timer.daemon = True self._timer.start() async def _get_jsession_id(self) -> Tuple[str, str, str]: """ 获取 JSESSIONID, tid 和 orgId :returns: Tuple[str, str, str] - **JSESSIONID** (str) – JSESSIONID - **tid** (str) – tid - **orgId** (str) – orgId :rtype: Tuple[str,str, str] :raises ECardTokenException: 获取 JSESSIONID, tid 或 orgId 失败时抛出 """ headers = { "User-Agent": self._parent._DeviceParams.userAgentPrecursor + "SuperApp", "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-Encoding": "gzip, deflate, br, zstd", "sec-ch-ua": '"Android WebView";v="129", "Not=A?Brand";v="8", "Chromium";v="129"', "sec-ch-ua-mobile": "?1", "sec-ch-ua-platform": '"Android"', "Upgrade-Insecure-Requests": "1", "x-id-token": self._parent._userToken, "X-Requested-With": "com.supwisdom.zzu", "Sec-Fetch-Site": "none", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-User": "?1", "Sec-Fetch-Dest": "document", "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", } params = { "host": "11", "org": "2", "token": self._parent._userToken, } logger.debug("尝试获取 JSessionID 和 tid") logger.debug(f"/auth/host/open 请求头:{headers}") response = await self._parent._client.get( "https://ecard.v.zzu.edu.cn/server/auth/host/open", params=params, headers=headers, follow_redirects=False, ) logger.debug(f"/auth/host/open 请求响应体:{response.text}") logger.debug(f"/auth/host/open 请求响应头:{response.headers}") try: return ( response.cookies.get("JSESSIONID"), parse_qs(urlparse(response.headers["location"]).query)["tid"][0], parse_qs(urlparse(response.headers["location"]).query)["orgId"][0], ) except Exception as exc: logger.error("获取 JSESSIONID 和 tid 失败") raise ECardTokenException( "获取 JSESSIONID 和 tid 失败, 通过 DEBUG 日志获得更多信息" ) from exc async def _get_ecard_access_token(self): """ 获取 ecard access token """ headers = { "User-Agent": self._parent._DeviceParams.userAgentPrecursor + "SuperApp", "Accept-Encoding": "gzip, deflate, br, zstd", "Content-Type": "application/json", "sec-ch-ua-platform": '"Android"', "sec-ch-ua": '"Android WebView";v="129", "Not=A?Brand";v="8", "Chromium";v="129"', "sec-ch-ua-mobile": "?1", "Origin": "https://ecard.v.zzu.edu.cn", "X-Requested-With": "com.supwisdom.zzu", "Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Dest": "empty", "Referer": f"https://ecard.v.zzu.edu.cn/?tid={self._tid}&{self._orgId}", "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", } data = { "tid": self._tid, } logger.debug(headers) response = await self._parent._client.post( "https://ecard.v.zzu.edu.cn/server/auth/getToken", headers=headers, json=data, ) logger.debug(f"/auth/getToken 请求响应体:{response.text}") try: return json.loads(response.text)["resultData"]["accessToken"], json.loads( response.text )["resultData"]["refreshToken"] except Exception as exc: logger.error("获取 eCardAccessToken 失败") raise ECardTokenException( "获取 eCardAccessToken 失败, 通过 DEBUG 日志获得更多信息" ) from exc
[文档] def get_default_room(self) -> str: """ 获取账户默认房间 :returns: 默认的房间 """ return sync_wrapper(self.get_default_room_async)()
[文档] async def get_default_room_async(self) -> str: """ 异步获取账户默认房间 :returns: 默认的房间 """ check_permission(self._parent) logger.debug("尝试获取默认 room") headers = { "User-Agent": self._parent._DeviceParams.userAgentPrecursor + "SuperApp", "Content-Type": "application/json", "sec-ch-ua-platform": '"Android"', "Authorization": self._eCardAccessToken, "sec-ch-ua": '"Not(A:Brand";v="99", "Android WebView";v="133", "Chromium";v="133"', "sec-ch-ua-mobile": "?1", "Origin": "https://ecard.v.zzu.edu.cn", "X-Requested-With": "com.supwisdom.zzu", "Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Dest": "empty", "Referer": f"https://ecard.v.zzu.edu.cn/?tid={self._tid}&{self._orgId}", "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", } data = { "utilityType": "electric", } response = await self._parent._client.post( "https://ecard.v.zzu.edu.cn/server/utilities/config", headers=headers, json=data, ) try: room = json.loads(response.text)["resultData"]["location"]["room"] logger.debug(f"默认 room 为 {room}") return room except Exception as exc: logger.error("获取默认 room 失败") raise DefaultRoomException( "获取默认 room 失败, 通过 DEBUG 日志获得更多信息" ) from exc
[文档] def recharge_energy( self, payment_password: str, amt: int, room: str | None = None ) -> Tuple[bool, str]: """ 为 room 充值电费 :param str room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取 :param str payment_password: 支付密码 :param int amt: 充值金额 :returns: Tuple[bool, str] - **success** (bool) – 充值是否成功 - **msg** (str) – 服务端返回信息。 :rtype: Tuple[bool,str] """ return sync_wrapper(self.recharge_energy_async)(payment_password, amt, room)
[文档] async def recharge_energy_async( self, payment_password: str, amt: int, room: str | None = None ) -> Tuple[bool, str]: """ 异步为 room 充值电费 :param str room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取 :param str payment_password: 支付密码 :param int amt: 充值金额 :returns: Tuple[bool, str] - **success** (bool) – 充值是否成功 - **msg** (str) – 服务端返回信息。 :rtype: Tuple[bool,str] """ check_permission(self._parent) room = await self.get_default_room_async() if room is None else room headers = { "Accept": "*/*", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Authorization": self._eCardAccessToken, "Cache-Control": "no-cache", "Connection": "keep-alive", "Content-Type": "application/json", "Origin": "https://ecard.v.zzu.edu.cn", "Pragma": "no-cache", "Referer": f"https://ecard.v.zzu.edu.cn/?tid={self._tid}&{self._orgId}", "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", "sec-ch-ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"', "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', } response = await self._parent._client.post( "https://ecard.v.zzu.edu.cn/server/auth/getEncrypt", headers=headers, ) pay_id = json.loads(response.text)["resultData"]["id"] encrypted_public_key = json.loads(response.text)["resultData"]["publicKey"] # 解密被加密的公钥 public_key = sm4_decrypt_ecb( base64.b64decode(encrypted_public_key), bytes.fromhex("773638372d392b33435f48266a655f35"), ) # 请求体明文 json_data = { "utilityType": "electric", "payCode": "06", "password": payment_password, "amt": str(amt), "timestamp": int(round(time.time() * 1000)), "bigArea": "", "area": room.split("--")[0].split("-")[0], "building": room.split("--")[0].split("-")[1], "unit": "", "level": room.split("--")[1].split("-")[0], "room": room, "subArea": "", "customfield": {}, } json_string = json.dumps(json_data, separators=(",", ":")) # 加密 params sm2 = gmalg.SM2(pk=bytes.fromhex(public_key)) encrypted_params = sm2.encrypt(json_string.encode()) data = {"id": pay_id, "params": (encrypted_params.hex())[2:]} response = await self._parent._client.post( "https://ecard.v.zzu.edu.cn/server/utilities/pay", headers=headers, json=data, ) return ( json.loads(response.text)["success"], json.loads(response.text)["message"], )
[文档] def get_balance(self) -> float: """ 获取校园卡余额 :return: 校园卡余额 :rtype: float """ return sync_wrapper(self.get_balance_async)()
[文档] async def get_balance_async(self) -> float: """ 异步获取校园卡余额 :return: 校园卡余额 :rtype: float """ check_permission(self._parent) headers = { "User-Agent": self._parent._DeviceParams.userAgentPrecursor + "uni-app Html5Plus/1.0 (Immersed/38.666668)", "Connection": "Keep-Alive", "Accept": "application/json", "Accept-Encoding": "gzip", "X-Device-Info": self._parent._DeviceParams.deviceInfo, "X-Device-Infos": self._parent._DeviceParams.deviceInfos, "X-Id-Token": self._parent._userToken, "X-Terminal-Info": "app", "Content-Type": "application/x-www-form-urlencoded", } response = await self._parent._client.get( "https://info.s.zzu.edu.cn/portal-api/v1/thrid-adapter/get-person-info-card-list", headers=headers, ) return float(json.loads(response.text)["data"][1]["amount"])
[文档] def get_room_dict(self, id: str) -> dict: """ 获取房间的字典 :param str id: 已知房间 ID 。例如: '', '99', '99-12', '99-12--33' :return: 对应的字典 :rtype: dict """ return sync_wrapper(self.get_room_dict_async)(id)
[文档] async def get_room_dict_async(self, id: str) -> dict: """ 异步获取房间的字典 :param str id: 已知房间 ID 。例如: '', '99', '99-12', '99-12--33' :return: 对应的字典 :rtype: dict """ check_permission(self._parent) num = id.count("-") if num == 0 and id == "": area = building = level = "" location_type = "bigArea" elif num == 0 and id != "": building = level = "" area = id location_type = "building" elif num == 1: area, building = id.split("-") level = "" location_type = "unit" elif num == 3: area, building = id.split("--")[0].split("-") level = id.split("--")[1] location_type = "room" else: raise ValueError("参数不合法") headers = { "User-Agent": self._parent._DeviceParams.userAgentPrecursor + "SuperApp", "Accept-Encoding": "gzip, deflate, br, zstd", "Content-Type": "application/json", "sec-ch-ua-platform": '"Android"', "Authorization": self._eCardAccessToken, "sec-ch-ua": '"Android WebView";v="129", "Not=A?Brand";v="8", "Chromium";v="129"', "sec-ch-ua-mobile": "?1", "Origin": "https://ecard.v.zzu.edu.cn", "X-Requested-With": "com.supwisdom.zzu", "Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Dest": "empty", "Referer": f"https://ecard.v.zzu.edu.cn/?tid={self._tid}&{self._orgId}", "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", } data = { "utilityType": "electric", "locationType": location_type, "bigArea": "", "area": area, "building": building, "unit": "", "level": level, "room": "", "subArea": "", } response = await self._parent._client.post( "https://ecard.v.zzu.edu.cn/server/utilities/location", headers=headers, json=data, ) RoomDict = {} for i in range(len(json.loads(response.text)["resultData"]["locationList"])): RoomDict[ json.loads(response.text)["resultData"]["locationList"][i]["id"] ] = json.loads(response.text)["resultData"]["locationList"][i]["name"] return RoomDict
[文档] def get_remaining_energy(self, room: str | None = None) -> float: """ 获取剩余电量 :param str room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取 :return: 剩余能源 :rtype: float """ return sync_wrapper(self.get_remaining_energy_async)(room)
[文档] async def get_remaining_energy_async(self, room: str | None = None) -> float: """ 异步获取剩余电量 :param str room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取 :return: 剩余能源 :rtype: float """ check_permission(self._parent) room = await self.get_default_room_async() if room is None else room headers = { "User-Agent": self._parent._DeviceParams.userAgentPrecursor + "SuperApp", "Accept-Encoding": "gzip, deflate, br, zstd", "Content-Type": "application/json", "sec-ch-ua-platform": '"Android"', "Authorization": self._eCardAccessToken, "sec-ch-ua": '"Android WebView";v="129", "Not=A?Brand";v="8", "Chromium";v="129"', "sec-ch-ua-mobile": "?1", "Origin": "https://ecard.v.zzu.edu.cn", "X-Requested-With": "com.supwisdom.zzu", "Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Dest": "empty", "Referer": f"https://ecard.v.zzu.edu.cn/?tid={self._tid}&{self._orgId}", "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", } data = { "utilityType": "electric", "bigArea": "", "area": room.split("--")[0].split("-")[0], "building": room.split("--")[0].split("-")[1], "unit": "", "level": room.split("--")[1].split("-")[0], "room": room, "subArea": "", } response = await self._parent._client.post( "https://ecard.v.zzu.edu.cn/server/utilities/account", headers=headers, json=data, ) return float( json.loads(response.text)["resultData"]["templateList"][3]["value"] )
[文档] def get_remaining_power(self, room: str | None = None) -> float: """ 获取剩余电量 已被废弃,请使用 get_remaining_energy() :param str room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取 :return: 剩余能源 :rtype: float """ logger.warning("get_remaining_power() 已废弃,请使用 get_remaining_energy()") warnings.warn( "get_remaining_power() is deprecated, please use get_remaining_energy()", DeprecationWarning, ) return self.get_remaining_energy(room)
[文档] def recharge_electricity( self, payment_password: str, amt: int, room: str | None = None ) -> Tuple[bool, str]: """ 为 room 充值电费 已被废弃,请使用 recharge_energy() :param str room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取 :param str payment_password: 支付密码 :param int amt: 充值金额 :returns: Tuple[bool, str] - **success** (bool) – 充值是否成功 - **msg** (str) – 服务端返回信息。 :rtype: Tuple[bool,str] """ logger.warning("recharge_electricity() 已废弃,请使用 recharge_energy()") warnings.warn( "recharge_electricity() is deprecated, please use recharge_energy()", DeprecationWarning, ) return self.recharge_energy(payment_password, amt, room)
[文档] async def recharge_electricity_async( self, payment_password: str, amt: int, room: str | None = None ) -> Tuple[bool, str]: """ 异步为 room 充值电费 已被废弃,请使用 recharge_energy_async() :param str room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取 :param str payment_password: 支付密码 :param int amt: 充值金额 :returns: Tuple[bool, str] - **success** (bool) – 充值是否成功 - **msg** (str) – 服务端返回信息。 :rtype: Tuple[bool,str] """ logger.warning( "recharge_electricity_async() 已废弃,请使用 recharge_energy_async()" ) warnings.warn( "recharge_electricity_async() is deprecated, please use recharge_energy_async()", DeprecationWarning, ) return await self.recharge_energy_async(payment_password, amt, room)
async def _start_token_refresh_async(self): """ 异步启动 token 刷新 """ self._JSessionID, self._tid, self._orgId = await self._get_jsession_id() ( self._eCardAccessToken, self._eCardRefreshToken, ) = await self._get_ecard_access_token() # 每 45 分钟(2700 秒)执行一次 self._timer = threading.Timer(2700, self._start_token_refresh_timer) self._timer.daemon = True self._timer.start()
[文档] async def start_token_refresh_loop(self): """ 启动异步的 token 刷新循环 """ import asyncio while True: # 刷新 token await self._start_token_refresh_async() # 等待 45 分钟(2700 秒) await asyncio.sleep(2700)