新增 ISIM 电费查缴系统

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

View File

@@ -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

View File

@@ -1,5 +1,4 @@
import json
import os
from pathlib import Path
from typing import Any, Dict, Optional
from loguru import logger

View File

@@ -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)

View File

@@ -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("数据库连接初始化完成")

26
database/isim.py Normal file
View File

@@ -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())
# 注释:电费记录和充值记录都实时获取,不存储在数据库中

View File

@@ -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

326
docs/ISIM_API.md Normal file
View File

@@ -0,0 +1,326 @@
# ISIM 电费查询系统 API 文档
## 概述
ISIMIntegrated 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

View File

@@ -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)

30
pdm.lock generated
View File

@@ -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"

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ license = {text = "MIT"}
[project.optional-dependencies]
dev = [
"black>=25.1.0",
"ruff>=0.12.11",
]
[tool.pdm]
distribution = false

338
router/isim/__init__.py Normal file
View File

@@ -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
)

140
router/isim/model.py Normal file
View File

@@ -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="绑定状态信息")

View File

@@ -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

View File

@@ -95,7 +95,7 @@ async def check_auth_status(
userid=user.userid
),
)
except Exception as e:
except Exception:
# token无效或其他错误
return AuthmeResponse(
code=401,

View File

@@ -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 (

View File

@@ -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

View File

@@ -1,4 +1,3 @@
import os
import uuid
import json
import base64

View File

@@ -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