diff --git a/config/logger.py b/config/logger.py index 9c4e13a..88d0eee 100644 --- a/config/logger.py +++ b/config/logger.py @@ -2,7 +2,6 @@ import sys from pathlib import Path from richuru import install from loguru import logger -from typing import Any, Dict from .manager import config_manager diff --git a/config/manager.py b/config/manager.py index c0402d6..1248a40 100644 --- a/config/manager.py +++ b/config/manager.py @@ -1,5 +1,4 @@ import json -import os from pathlib import Path from typing import Any, Dict, Optional from loguru import logger diff --git a/config/models.py b/config/models.py index a7f7260..41d911d 100644 --- a/config/models.py +++ b/config/models.py @@ -27,6 +27,16 @@ class DatabaseConfig(BaseModel): pool_recycle: int = Field(default=3600, description="连接回收时间(秒)") +class ISIMConfig(BaseModel): + """ISIM后勤电费系统配置""" + base_url: str = Field( + default="http://hqkd-aufe-edu-cn.vpn2.aufe.edu.cn/", + description="ISIM系统基础URL" + ) + session_timeout: int = Field(default=1800, description="会话超时时间(秒)") + retry_times: int = Field(default=3, description="请求重试次数") + + class AUFEConfig(BaseModel): """AUFE连接配置""" default_timeout: int = Field(default=30, description="默认超时时间(秒)") @@ -140,6 +150,7 @@ class Settings(BaseModel): """主配置类""" database: DatabaseConfig = Field(default_factory=DatabaseConfig) aufe: AUFEConfig = Field(default_factory=AUFEConfig) + isim: ISIMConfig = Field(default_factory=ISIMConfig) s3: S3Config = Field(default_factory=S3Config) log: LogConfig = Field(default_factory=LogConfig) app: AppConfig = Field(default_factory=AppConfig) diff --git a/database/creator.py b/database/creator.py index 7853628..bf6ca56 100644 --- a/database/creator.py +++ b/database/creator.py @@ -45,7 +45,7 @@ class DatabaseManager: logger.error(f"数据库连接初始化失败: {e}") logger.error(f"数据库连接URL: {db_config.url}") logger.error(f"数据库连接配置: {db_config}") - logger.error(f"请启动config_tui.py来配置数据库连接") + logger.error("请启动config_tui.py来配置数据库连接") raise logger.info("数据库连接初始化完成") diff --git a/database/isim.py b/database/isim.py new file mode 100644 index 0000000..adefcb9 --- /dev/null +++ b/database/isim.py @@ -0,0 +1,26 @@ +import datetime + +from sqlalchemy import func, String +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column +from database.base import Base + + +class ISIMRoomBinding(Base): + """ISIM系统房间绑定表""" + __tablename__ = "isim_room_binding_table" + + id: Mapped[int] = mapped_column(primary_key=True) + userid: Mapped[str] = mapped_column(String(100), nullable=False, index=True, comment="用户ID") + building_code: Mapped[str] = mapped_column(String(10), nullable=False, comment="楼栋代码") + building_name: Mapped[str] = mapped_column(String(100), nullable=False, comment="楼栋名称") + floor_code: Mapped[str] = mapped_column(String(10), nullable=False, comment="楼层代码") + floor_name: Mapped[str] = mapped_column(String(50), nullable=False, comment="楼层名称") + room_code: Mapped[str] = mapped_column(String(20), nullable=False, comment="房间代码") + room_name: Mapped[str] = mapped_column(String(50), nullable=False, comment="房间名称") + room_id: Mapped[str] = mapped_column(String(20), nullable=False, comment="房间ID(楼栋+楼层+房间)") + create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now()) + update_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) + + +# 注释:电费记录和充值记录都实时获取,不存储在数据库中 diff --git a/database/user.py b/database/user.py index 6acc422..b3c6f9c 100644 --- a/database/user.py +++ b/database/user.py @@ -1,7 +1,7 @@ import datetime from typing import Optional -from sqlalchemy import func, String, Text +from sqlalchemy import func, String from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column from database.base import Base diff --git a/docs/ISIM_API.md b/docs/ISIM_API.md new file mode 100644 index 0000000..aeecfaf --- /dev/null +++ b/docs/ISIM_API.md @@ -0,0 +1,326 @@ +# ISIM 电费查询系统 API 文档 + +## 概述 + +ISIM(Integrated Student Information Management)电费查询系统是为安徽财经大学学生提供的后勤电费查询服务。通过该系统,学生可以: + +- 选择和绑定宿舍房间 +- 查询电费余额和用电记录 +- 查看充值记录 + +## API 端点 + +### 认证 + +所有API都需要通过认证令牌(authme_token)进行身份验证。认证信息通过依赖注入自动处理。 + +### 房间选择器 API + +#### 1. 获取楼栋列表 + +**POST** `/api/v1/isim/picker/building/get` + +获取所有可选择的楼栋信息。 + +**响应示例:** +```json +{ + "code": 0, + "message": "楼栋列表获取成功", + "data": [ + { + "code": "11", + "name": "北苑11号学生公寓" + }, + { + "code": "12", + "name": "北苑12号学生公寓" + } + ] +} +``` + +#### 2. 设置楼栋并获取楼层列表 + +**POST** `/api/v1/isim/picker/building/set` + +设置楼栋并获取对应的楼层列表。 + +**请求参数:** +```json +{ + "building_code": "11" +} +``` + +**响应示例:** +```json +{ + "code": 0, + "message": "楼层列表获取成功", + "data": [ + { + "code": "010101", + "name": "1-1层" + }, + { + "code": "010102", + "name": "1-2层" + } + ] +} +``` + +#### 3. 设置楼层并获取房间列表 + +**POST** `/api/v1/isim/picker/floor/set` + +设置楼层并获取对应的房间列表。 + +**请求参数:** +```json +{ + "floor_code": "010101" +} +``` + +**响应示例:** +```json +{ + "code": 0, + "message": "房间列表获取成功", + "data": [ + { + "code": "01", + "name": "1-101" + }, + { + "code": "02", + "name": "1-102" + } + ] +} +``` + +#### 4. 绑定房间 + +**POST** `/api/v1/isim/picker/room/set` + +绑定房间到用户账户。 + +**请求参数:** +```json +{ + "building_code": "11", + "floor_code": "010101", + "room_code": "01" +} +``` + +**响应示例:** +```json +{ + "code": 0, + "message": "房间绑定成功", + "data": { + "building": { + "code": "11", + "name": "北苑11号学生公寓" + }, + "floor": { + "code": "010101", + "name": "1-1层" + }, + "room": { + "code": "01", + "name": "1-101" + }, + "room_id": "01", + "display_text": "北苑11号学生公寓/1-1层/1-101" + } +} +``` + +### 电费查询 API + +#### 5. 获取电费信息 + +**POST** `/api/v1/isim/electricity/info` + +获取电费余额和用电记录信息。 + +**响应示例:** +```json +{ + "code": 0, + "message": "电费信息获取成功", + "data": { + "balance": { + "remaining_purchased": 815.30, + "remaining_subsidy": 2198.01 + }, + "usage_records": [ + { + "record_time": "2025-08-29 00:04:58", + "usage_amount": 0.00, + "meter_name": "1-101" + }, + { + "record_time": "2025-08-29 00:04:58", + "usage_amount": 0.00, + "meter_name": "1-101空调" + } + ] + } +} +``` + +#### 6. 获取充值信息 + +**POST** `/api/v1/isim/payment/info` + +获取电费余额和充值记录信息。 + +**响应示例:** +```json +{ + "code": 0, + "message": "充值信息获取成功", + "data": { + "balance": { + "remaining_purchased": 815.30, + "remaining_subsidy": 2198.01 + }, + "payment_records": [ + { + "payment_time": "2025-02-21 11:30:08", + "amount": 71.29, + "payment_type": "下发补助" + }, + { + "payment_time": "2024-09-01 15:52:40", + "amount": 71.29, + "payment_type": "下发补助" + } + ] + } +} +``` + +#### 7. 检查房间绑定状态 + +**POST** `/api/v1/isim/room/binding/status` + +检查用户是否已绑定宿舍房间。 + +**已绑定响应示例:** +```json +{ + "code": 0, + "message": "用户已绑定宿舍房间", + "data": { + "is_bound": true, + "binding_info": { + "building": { + "code": "35", + "name": "西校荆苑5号学生公寓" + }, + "floor": { + "code": "3501", + "name": "荆5-1层" + }, + "room": { + "code": "350116", + "name": "J5-116" + }, + "room_id": "350116", + "display_text": "西校荆苑5号学生公寓/荆5-1层/J5-116" + } + } +} +``` + +**未绑定响应示例:** +```json +{ + "code": 0, + "message": "用户未绑定宿舍房间", + "data": { + "is_bound": false, + "binding_info": null + } +} +``` + +## 错误处理 + +### 标准错误响应 + +```json +{ + "code": 1, + "message": "错误描述信息" +} +``` + +### 认证错误响应 + +```json +{ + "code": 401, + "message": "Cookie已失效或不在VPN/校园网环境,请重新登录" +} +``` + +### 常见错误代码 + +- `0`: 成功 +- `1`: 一般业务错误 +- `400`: 请求参数错误或未绑定房间 +- `401`: 认证失败 +- `500`: 服务器内部错误 + +### 特殊错误情况 + +#### 未绑定房间错误 + +当用户尝试查询电费或充值信息但未绑定房间时,会返回特定错误: + +```json +{ + "code": 400, + "message": "请先绑定宿舍房间后再查询电费信息" +} +``` + +或 + +```json +{ + "code": 400, + "message": "请先绑定宿舍房间后再查询充值信息" +} +``` + +## 使用流程 + +### 房间绑定流程 +1. **检查绑定状态**:调用房间绑定状态API检查是否已绑定 +2. **首次绑定**(如果未绑定): + - 调用楼栋列表API获取所有楼栋 + - 调用楼栋设置API获取楼层列表 + - 调用楼层设置API获取房间列表 + - 调用房间绑定API完成房间绑定 + +### 查询流程 +1. **确认绑定**:确保用户已绑定房间(必需) +2. **查询信息**:调用电费信息或充值信息API获取数据 + +## 注意事项 + +- 所有接口都需要有效的认证令牌 +- 数据实时从后勤系统获取,不会在数据库中缓存 +- 房间绑定信息会保存在数据库中以便下次使用 +- 系统需要VPN或校园网环境才能正常访问 +- **电费查询和充值查询需要先绑定房间**,否则会返回400错误 +- 访问`/go`端点会返回302重定向,系统会自动处理并提取JSESSIONID diff --git a/main.py b/main.py index 72d7299..caa3153 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,7 @@ from router.jwc import jwc_router from router.login import login_router from router.aac import aac_router from router.user import user_router +from router.isim import isim_router from richuru import install from fastapi.middleware.cors import CORSMiddleware as allow_origins import uvicorn @@ -74,6 +75,7 @@ app.include_router(jwc_router) app.include_router(login_router) app.include_router(aac_router) app.include_router(user_router) +app.include_router(isim_router) if __name__ == "__main__": uvicorn.run(app, host=app_config.host, port=app_config.port) \ No newline at end of file diff --git a/pdm.lock b/pdm.lock index a9db4ee..44ac1e4 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:a5fa6665afd4fc15854ad1f8b64290ee3056e1948254d5a2867538f6d4b51da6" +content_hash = "sha256:906aa83c21919f8fa22d06361e5108ffb44a5c16d5e18ae54d1525c4601ba499" [[metadata.targets]] requires_python = "==3.12.*" @@ -843,6 +843,34 @@ files = [ {file = "richuru-0.1.1.tar.gz", hash = "sha256:815816f3a0142b67c53bd424fcced0821aa1104b5386301cff47fc7bbb6d90ea"}, ] +[[package]] +name = "ruff" +version = "0.12.11" +requires_python = ">=3.7" +summary = "An extremely fast Python linter and code formatter, written in Rust." +groups = ["dev"] +files = [ + {file = "ruff-0.12.11-py3-none-linux_armv6l.whl", hash = "sha256:93fce71e1cac3a8bf9200e63a38ac5c078f3b6baebffb74ba5274fb2ab276065"}, + {file = "ruff-0.12.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8e33ac7b28c772440afa80cebb972ffd823621ded90404f29e5ab6d1e2d4b93"}, + {file = "ruff-0.12.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d69fb9d4937aa19adb2e9f058bc4fbfe986c2040acb1a4a9747734834eaa0bfd"}, + {file = "ruff-0.12.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:411954eca8464595077a93e580e2918d0a01a19317af0a72132283e28ae21bee"}, + {file = "ruff-0.12.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a2c0a2e1a450f387bf2c6237c727dd22191ae8c00e448e0672d624b2bbd7fb0"}, + {file = "ruff-0.12.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ca4c3a7f937725fd2413c0e884b5248a19369ab9bdd850b5781348ba283f644"}, + {file = "ruff-0.12.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4d1df0098124006f6a66ecf3581a7f7e754c4df7644b2e6704cd7ca80ff95211"}, + {file = "ruff-0.12.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a8dd5f230efc99a24ace3b77e3555d3fbc0343aeed3fc84c8d89e75ab2ff793"}, + {file = "ruff-0.12.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc75533039d0ed04cd33fb8ca9ac9620b99672fe7ff1533b6402206901c34ee"}, + {file = "ruff-0.12.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc58f9266d62c6eccc75261a665f26b4ef64840887fc6cbc552ce5b29f96cc8"}, + {file = "ruff-0.12.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5a0113bd6eafd545146440225fe60b4e9489f59eb5f5f107acd715ba5f0b3d2f"}, + {file = "ruff-0.12.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0d737b4059d66295c3ea5720e6efc152623bb83fde5444209b69cd33a53e2000"}, + {file = "ruff-0.12.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:916fc5defee32dbc1fc1650b576a8fed68f5e8256e2180d4d9855aea43d6aab2"}, + {file = "ruff-0.12.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c984f07d7adb42d3ded5be894fb4007f30f82c87559438b4879fe7aa08c62b39"}, + {file = "ruff-0.12.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e07fbb89f2e9249f219d88331c833860489b49cdf4b032b8e4432e9b13e8a4b9"}, + {file = "ruff-0.12.11-py3-none-win32.whl", hash = "sha256:c792e8f597c9c756e9bcd4d87cf407a00b60af77078c96f7b6366ea2ce9ba9d3"}, + {file = "ruff-0.12.11-py3-none-win_amd64.whl", hash = "sha256:a3283325960307915b6deb3576b96919ee89432ebd9c48771ca12ee8afe4a0fd"}, + {file = "ruff-0.12.11-py3-none-win_arm64.whl", hash = "sha256:bae4d6e6a2676f8fb0f98b74594a048bae1b944aab17e9f5d504062303c6dbea"}, + {file = "ruff-0.12.11.tar.gz", hash = "sha256:c6b09ae8426a65bbee5425b9d0b82796dbb07cb1af045743c79bfb163001165d"}, +] + [[package]] name = "s3transfer" version = "0.13.1" diff --git a/provider/aufe/aac/__init__.py b/provider/aufe/aac/__init__.py index 4286b20..9a204e1 100644 --- a/provider/aufe/aac/__init__.py +++ b/provider/aufe/aac/__init__.py @@ -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 diff --git a/provider/aufe/aac/model.py b/provider/aufe/aac/model.py index 64d2631..cd0e999 100644 --- a/provider/aufe/aac/model.py +++ b/provider/aufe/aac/model.py @@ -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=[] ) diff --git a/provider/aufe/client.py b/provider/aufe/client.py index 067cb55..059acc1 100644 --- a/provider/aufe/client.py +++ b/provider/aufe/client.py @@ -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": """ diff --git a/provider/aufe/isim/__init__.py b/provider/aufe/isim/__init__.py new file mode 100644 index 0000000..4f317bb --- /dev/null +++ b/provider/aufe/isim/__init__.py @@ -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("警告: 未获取到twfid,VPN访问可能会失败") + + 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() diff --git a/provider/aufe/isim/depends.py b/provider/aufe/isim/depends.py new file mode 100644 index 0000000..9c2f610 --- /dev/null +++ b/provider/aufe/isim/depends.py @@ -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 diff --git a/provider/aufe/isim/model.py b/provider/aufe/isim/model.py new file mode 100644 index 0000000..efb6904 --- /dev/null +++ b/provider/aufe/isim/model.py @@ -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] = [] diff --git a/provider/aufe/jwc/__init__.py b/provider/aufe/jwc/__init__.py index 572a6bc..21b5e16 100644 --- a/provider/aufe/jwc/__init__.py +++ b/provider/aufe/jwc/__init__.py @@ -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: diff --git a/provider/loveac/authme.py b/provider/loveac/authme.py index 3d31f1a..9f3d9ad 100644 --- a/provider/loveac/authme.py +++ b/provider/loveac/authme.py @@ -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): diff --git a/pyproject.toml b/pyproject.toml index e8ed2c1..c2b86e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ license = {text = "MIT"} [project.optional-dependencies] dev = [ "black>=25.1.0", + "ruff>=0.12.11", ] [tool.pdm] distribution = false \ No newline at end of file diff --git a/router/isim/__init__.py b/router/isim/__init__.py new file mode 100644 index 0000000..9757e5e --- /dev/null +++ b/router/isim/__init__.py @@ -0,0 +1,338 @@ +from fastapi import Depends +from fastapi.routing import APIRouter +from provider.aufe.isim import ISIMClient +from provider.aufe.isim.depends import get_isim_client +from provider.loveac.authme import AuthmeResponse +from router.isim.model import ( + BuildingListResponse, + FloorListResponse, + RoomListResponse, + RoomBindingResponse, + ElectricityInfoResponse, + PaymentInfoResponse, + RoomBindingStatusData, + RoomBindingStatusResponse, +) +from router.common_model import ErrorResponse +from loguru import logger +from sqlalchemy.ext.asyncio import AsyncSession +from database.creator import get_db_session +from database.isim import ISIMRoomBinding +from sqlalchemy import select, update +from pydantic import BaseModel +from provider.aufe.isim.model import BuildingInfo, FloorInfo, RoomInfo, RoomBindingInfo + + +# 简化的请求模型,只需要业务参数 +class SetBuildingRequest(BaseModel): + building_code: str + + +class SetFloorRequest(BaseModel): + floor_code: str + + +class SetRoomRequest(BaseModel): + building_code: str + floor_code: str + room_code: str + + +isim_router = APIRouter(prefix="/api/v1/isim") + + +# ==================== 房间选择器API ==================== + + +@isim_router.post( + "/picker/building/get", + summary="获取楼栋列表", + response_model=BuildingListResponse | AuthmeResponse | ErrorResponse, +) +async def get_building_list(client: ISIMClient = Depends(get_isim_client)): + """获取所有可选楼栋列表""" + try: + result = await client.get_buildings() + + response = BuildingListResponse.from_data( + data=result, + success_message="楼栋列表获取成功", + error_message="获取楼栋列表失败,网络请求多次重试后仍无法连接后勤系统,请稍后重试或联系管理员", + ) + return response + + except Exception as e: + logger.error(f"获取楼栋列表时发生系统错误: {str(e)}") + return ErrorResponse(message=f"获取楼栋列表时发生系统错误:{str(e)}", code=500) + + +@isim_router.post( + "/picker/building/set", + summary="设置楼栋并获取楼层列表", + response_model=FloorListResponse | AuthmeResponse | ErrorResponse, +) +async def set_building_get_floors( + request: SetBuildingRequest, client: ISIMClient = Depends(get_isim_client) +): + """设置楼栋并获取对应的楼层列表""" + try: + result = await client.get_floors(request.building_code) + + response = FloorListResponse.from_data( + data=result, + success_message="楼层列表获取成功", + error_message="获取楼层列表失败,网络请求多次重试后仍无法连接后勤系统,请稍后重试或联系管理员", + ) + return response + + except Exception as e: + logger.error(f"获取楼层列表时发生系统错误: {str(e)}") + return ErrorResponse(message=f"获取楼层列表时发生系统错误:{str(e)}", code=500) + + +@isim_router.post( + "/picker/floor/set", + summary="设置楼层并获取房间列表", + response_model=RoomListResponse | AuthmeResponse | ErrorResponse, +) +async def set_floor_get_rooms( + request: SetFloorRequest, client: ISIMClient = Depends(get_isim_client) +): + """设置楼层并获取对应的房间列表""" + try: + result = await client.get_rooms(request.floor_code) + + response = RoomListResponse.from_data( + data=result, + success_message="房间列表获取成功", + error_message="获取房间列表失败,网络请求多次重试后仍无法连接后勤系统,请稍后重试或联系管理员", + ) + return response + + except Exception as e: + logger.error(f"获取房间列表时发生系统错误: {str(e)}") + return ErrorResponse(message=f"获取房间列表时发生系统错误:{str(e)}", code=500) + + +@isim_router.post( + "/picker/room/set", + summary="绑定房间", + response_model=RoomBindingResponse | AuthmeResponse | ErrorResponse, +) +async def bind_room( + request: SetRoomRequest, + client: ISIMClient = Depends(get_isim_client), + asyncsession: AsyncSession = Depends(get_db_session), +): + """绑定房间并保存到数据库""" + try: + # 执行房间绑定 + result = await client.bind_room( + building_code=request.building_code, + floor_code=request.floor_code, + room_code=request.room_code, + ) + + if result: + # 保存绑定信息到数据库 + async with asyncsession as session: + try: + # 获取用户ID + user_id = client.vpn_connection.student_id + + # 检查是否已存在绑定记录 + existing_binding = await session.execute( + select(ISIMRoomBinding).where(ISIMRoomBinding.userid == user_id) + ) + existing = existing_binding.scalars().first() + + if existing: + # 更新现有记录 + await session.execute( + update(ISIMRoomBinding) + .where(ISIMRoomBinding.userid == user_id) + .values( + building_code=result.building.code, + building_name=result.building.name, + floor_code=result.floor.code, + floor_name=result.floor.name, + room_code=result.room.code, + room_name=result.room.name, + room_id=result.room_id, + ) + ) + logger.info( + f"更新用户房间绑定: {user_id} -> {result.display_text}" + ) + else: + # 创建新记录 + new_binding = ISIMRoomBinding( + userid=user_id, + building_code=result.building.code, + building_name=result.building.name, + floor_code=result.floor.code, + floor_name=result.floor.name, + room_code=result.room.code, + room_name=result.room.name, + room_id=result.room_id, + ) + session.add(new_binding) + logger.info( + f"创建用户房间绑定: {user_id} -> {result.display_text}" + ) + + await session.commit() + + except Exception as db_error: + await session.rollback() + logger.error(f"保存房间绑定到数据库失败: {str(db_error)}") + # 数据库保存失败不影响绑定结果返回 + + response = RoomBindingResponse.from_data( + data=result, + success_message="房间绑定成功", + error_message="房间绑定失败,网络请求多次重试后仍无法连接后勤系统,请稍后重试或联系管理员", + ) + return response + + except Exception as e: + logger.error(f"绑定房间时发生系统错误: {str(e)}") + return ErrorResponse(message=f"绑定房间时发生系统错误:{str(e)}", code=500) + + +# ==================== 电费查询API ==================== + + +@isim_router.post( + "/electricity/info", + summary="获取电费信息", + response_model=ElectricityInfoResponse | AuthmeResponse | ErrorResponse, +) +async def get_electricity_info( + client: ISIMClient = Depends(get_isim_client), + session: AsyncSession = Depends(get_db_session), +): + """获取电费余额和用电记录信息""" + try: + # 查询用户的房间绑定记录 + from database.isim import ISIMRoomBinding + from sqlalchemy import select + + result_query = await session.execute( + select(ISIMRoomBinding).where(ISIMRoomBinding.userid == client.user_id) + ) + binding_record = result_query.scalars().first() + # 传递绑定记录给客户端 + result = await client.get_electricity_info(binding_record) + + response = ElectricityInfoResponse.from_data( + data=result, + success_message="电费信息获取成功", + error_message="获取电费信息失败,网络请求多次重试后仍无法连接后勤系统,请稍后重试或联系管理员", + ) + return response + + except Exception as e: + logger.error(f"获取电费信息时发生系统错误: {str(e)}") + return ErrorResponse(message=f"获取电费信息时发生系统错误:{str(e)}", code=500) + + +@isim_router.post( + "/payment/info", + summary="获取充值信息", + response_model=PaymentInfoResponse | AuthmeResponse | ErrorResponse, +) +async def get_payment_info( + client: ISIMClient = Depends(get_isim_client), + session: AsyncSession = Depends(get_db_session), +): + """获取电费余额和充值记录信息""" + try: + # 查询用户的房间绑定记录 + from database.isim import ISIMRoomBinding + from sqlalchemy import select + + result_query = await session.execute( + select(ISIMRoomBinding).where(ISIMRoomBinding.userid == client.user_id) + ) + binding_record = result_query.scalars().first() + # 传递绑定记录给客户端 + result = await client.get_payment_info(binding_record) + + response = PaymentInfoResponse.from_data( + data=result, + success_message="充值信息获取成功", + error_message="获取充值信息失败,网络请求多次重试后仍无法连接后勤系统,请稍后重试或联系管理员", + ) + return response + + except Exception as e: + logger.error(f"获取充值信息时发生系统错误: {str(e)}") + return ErrorResponse(message=f"获取充值信息时发生系统错误:{str(e)}", code=500) + + +# ==================== 房间绑定状态API ==================== + + +@isim_router.post( + "/room/binding/status", + summary="检查用户房间绑定状态", + response_model=RoomBindingStatusResponse | AuthmeResponse | ErrorResponse, +) +async def check_room_binding_status( + client: ISIMClient = Depends(get_isim_client), + asyncsession: AsyncSession = Depends(get_db_session), +): + """检查用户是否已绑定宿舍房间""" + try: + # 获取用户ID + user_id = client.vpn_connection.student_id + + async with asyncsession as session: + # 查询数据库中的房间绑定记录 + result = await session.execute( + select(ISIMRoomBinding).where(ISIMRoomBinding.userid == user_id) + ) + binding_record = result.scalars().first() + + if binding_record: + # 用户已绑定房间,构建绑定信息 + binding_info = RoomBindingInfo( + building=BuildingInfo( + code=binding_record.building_code, + name=binding_record.building_name, + ), + floor=FloorInfo( + code=binding_record.floor_code, name=binding_record.floor_name + ), + room=RoomInfo( + code=binding_record.room_code, name=binding_record.room_name + ), + room_id=binding_record.room_id, + display_text=f"{binding_record.building_name}/{binding_record.floor_name}/{binding_record.room_name}", + ) + + status_data = RoomBindingStatusData( + is_bound=True, binding_info=binding_info + ) + + logger.info(f"用户 {user_id} 已绑定房间: {binding_info.display_text}") + + return RoomBindingStatusResponse.success( + data=status_data, message="用户已绑定宿舍房间" + ) + else: + # 用户未绑定房间 + status_data = RoomBindingStatusData(is_bound=False, binding_info=None) + + logger.info(f"用户 {user_id} 未绑定房间") + + return RoomBindingStatusResponse.success( + data=status_data, message="用户未绑定宿舍房间" + ) + + except Exception as e: + logger.error(f"检查房间绑定状态时发生系统错误: {str(e)}") + return ErrorResponse( + message=f"检查房间绑定状态时发生系统错误:{str(e)}", code=500 + ) diff --git a/router/isim/model.py b/router/isim/model.py new file mode 100644 index 0000000..5b23866 --- /dev/null +++ b/router/isim/model.py @@ -0,0 +1,140 @@ +from typing import List, Optional +from pydantic import BaseModel, Field +from provider.aufe.isim.model import ( + BuildingInfo, + FloorInfo, + RoomInfo, + RoomBindingInfo, + ElectricityInfo, + PaymentInfo +) +from router.common_model import BaseResponse + + +# ==================== 请求模型 ==================== + +class AuthmeRequest(BaseModel): + """认证请求基类""" + authme_token: str = Field(..., description="认证令牌") + + +class SetBuildingRequest(AuthmeRequest): + """设置楼栋请求""" + building_code: str = Field(..., description="楼栋代码") + + +class SetFloorRequest(AuthmeRequest): + """设置楼层请求""" + floor_code: str = Field(..., description="楼层代码") + + +class SetRoomRequest(AuthmeRequest): + """设置房间请求""" + building_code: str = Field(..., description="楼栋代码") + floor_code: str = Field(..., description="楼层代码") + room_code: str = Field(..., description="房间代码") + + +# ==================== 响应模型 ==================== + +class BuildingListResponse(BaseResponse): + """楼栋列表响应""" + data: Optional[List[BuildingInfo]] = Field(default=None, description="楼栋信息列表") + + @classmethod + def from_data(cls, data: List[BuildingInfo], success_message: str, error_message: str): + """根据数据创建响应""" + if data and len(data) > 0: + # 检查是否是错误数据(第一个楼栋名称为"请求失败") + if data[0].name == "请求失败": + return cls.error(message=error_message, code=500, data=None) + else: + return cls.success(data=data, message=success_message) + else: + return cls.error(message=error_message, code=500, data=None) + + +class FloorListResponse(BaseResponse): + """楼层列表响应""" + data: Optional[List[FloorInfo]] = Field(default=None, description="楼层信息列表") + + @classmethod + def from_data(cls, data: List[FloorInfo], success_message: str, error_message: str): + """根据数据创建响应""" + if data and len(data) > 0: + return cls.success(data=data, message=success_message) + else: + return cls.error(message=error_message, code=500, data=None) + + +class RoomListResponse(BaseResponse): + """房间列表响应""" + data: Optional[List[RoomInfo]] = Field(default=None, description="房间信息列表") + + @classmethod + def from_data(cls, data: List[RoomInfo], success_message: str, error_message: str): + """根据数据创建响应""" + if data and len(data) > 0: + return cls.success(data=data, message=success_message) + else: + return cls.error(message=error_message, code=500, data=None) + + +class RoomBindingResponse(BaseResponse): + """房间绑定响应""" + data: Optional[RoomBindingInfo] = Field(default=None, description="房间绑定信息") + + @classmethod + def from_data(cls, data: Optional[RoomBindingInfo], success_message: str, error_message: str): + """根据数据创建响应""" + if data and hasattr(data, 'building') and data.building.name != "请求失败": + return cls.success(data=data, message=success_message) + else: + return cls.error(message=error_message, code=500, data=None) + + +class ElectricityInfoResponse(BaseResponse): + """电费信息响应""" + data: Optional[ElectricityInfo] = Field(default=None, description="电费信息") + + @classmethod + def from_data(cls, data: ElectricityInfo, success_message: str, error_message: str): + """根据数据创建响应""" + # 检查是否是错误数据 + if data.balance.remaining_purchased >= 0 and data.balance.remaining_subsidy >= 0: + return cls.success(data=data, message=success_message) + elif data.balance.remaining_purchased == -2.0 and data.balance.remaining_subsidy == -2.0: + # 未绑定房间的特定错误 + return cls.error(message="请先绑定宿舍房间后再查询电费信息", code=400, data=None) + else: + return cls.error(message=error_message, code=500, data=None) + + +class PaymentInfoResponse(BaseResponse): + """充值信息响应""" + data: Optional[PaymentInfo] = Field(default=None, description="充值信息") + + @classmethod + def from_data(cls, data: PaymentInfo, success_message: str, error_message: str): + """根据数据创建响应""" + # 检查是否是错误数据 + if data.balance.remaining_purchased >= 0 and data.balance.remaining_subsidy >= 0: + return cls.success(data=data, message=success_message) + elif data.balance.remaining_purchased == -2.0 and data.balance.remaining_subsidy == -2.0: + # 未绑定房间的特定错误 + return cls.error(message="请先绑定宿舍房间后再查询充值信息", code=400, data=None) + else: + return cls.error(message=error_message, code=500, data=None) + + +# ==================== 房间绑定状态相关模型 ==================== + +class RoomBindingStatusData(BaseModel): + """房间绑定状态数据""" + is_bound: bool = Field(..., description="是否已绑定房间") + binding_info: Optional[RoomBindingInfo] = Field(default=None, description="绑定信息(如果已绑定)") + + +class RoomBindingStatusResponse(BaseResponse): + """房间绑定状态响应""" + data: Optional[RoomBindingStatusData] = Field(default=None, description="绑定状态信息") diff --git a/router/jwc/model.py b/router/jwc/model.py index e6e7bf3..3254ae9 100644 --- a/router/jwc/model.py +++ b/router/jwc/model.py @@ -6,7 +6,7 @@ from provider.aufe.jwc.model import ( ExamInfoResponse, TermScoreResponse, ) -from typing import List, Dict, Optional +from typing import List, Dict from pydantic import BaseModel, Field diff --git a/router/login/__init__.py b/router/login/__init__.py index b3b3a85..cf4885a 100644 --- a/router/login/__init__.py +++ b/router/login/__init__.py @@ -95,7 +95,7 @@ async def check_auth_status( userid=user.userid ), ) - except Exception as e: + except Exception: # token无效或其他错误 return AuthmeResponse( code=401, diff --git a/router/user/__init__.py b/router/user/__init__.py index e0638f1..f2ac72e 100644 --- a/router/user/__init__.py +++ b/router/user/__init__.py @@ -4,7 +4,7 @@ from fastapi.routing import APIRouter from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from database.creator import get_db_session -from database.user import UserProfile, User +from database.user import UserProfile from provider.loveac.authme import fetch_user_by_token, AuthmeRequest from utils.file_manager import file_manager from .model import ( diff --git a/router/user/model.py b/router/user/model.py index 6735d6e..d35f2a3 100644 --- a/router/user/model.py +++ b/router/user/model.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, Field, field_validator from router.common_model import BaseResponse -from typing import Optional, Dict, Any, Union +from typing import Optional, Union import json diff --git a/utils/file_manager.py b/utils/file_manager.py index 5e60523..40df733 100644 --- a/utils/file_manager.py +++ b/utils/file_manager.py @@ -1,4 +1,3 @@ -import os import uuid import json import base64 diff --git a/utils/s3_client.py b/utils/s3_client.py index 4c68a87..240314a 100644 --- a/utils/s3_client.py +++ b/utils/s3_client.py @@ -1,5 +1,4 @@ -import asyncio -from typing import Optional, Dict, Any, BinaryIO, Union +from typing import Optional, Dict, Any, Union from pathlib import Path from loguru import logger