⚡新增 ISIM 电费查缴系统
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from loguru import logger
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
26
database/isim.py
Normal 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())
|
||||
|
||||
|
||||
# 注释:电费记录和充值记录都实时获取,不存储在数据库中
|
||||
@@ -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
326
docs/ISIM_API.md
Normal file
@@ -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
|
||||
2
main.py
2
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)
|
||||
30
pdm.lock
generated
30
pdm.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=[]
|
||||
)
|
||||
|
||||
@@ -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":
|
||||
"""
|
||||
|
||||
877
provider/aufe/isim/__init__.py
Normal file
877
provider/aufe/isim/__init__.py
Normal 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("警告: 未获取到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()
|
||||
116
provider/aufe/isim/depends.py
Normal file
116
provider/aufe/isim/depends.py
Normal 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
169
provider/aufe/isim/model.py
Normal 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] = []
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
338
router/isim/__init__.py
Normal 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
140
router/isim/model.py
Normal 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="绑定状态信息")
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ async def check_auth_status(
|
||||
userid=user.userid
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# token无效或其他错误
|
||||
return AuthmeResponse(
|
||||
code=401,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
import base64
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user