⚒️ 重大重构 LoveACE V2

引入了 mongodb
对数据库进行了一定程度的数据加密
性能改善
代码简化
统一错误模型和响应
使用 apifox 作为文档
This commit is contained in:
2025-11-20 20:44:25 +08:00
parent 6b90c6d7bb
commit bbc86b8330
168 changed files with 14264 additions and 19152 deletions

114
loveace/config/logger.py Normal file
View File

@@ -0,0 +1,114 @@
from pathlib import Path
from loguru import logger
from loveace.config.manager import config_manager
from loveace.utils.richuru_hook import install
def setup_logger():
"""根据配置文件设置loguru日志"""
settings = config_manager.get_settings()
log_config = settings.log
# 移除默认的logger配置
logger.remove()
# 安装 richuru 并配置更详细的堆栈跟踪信息
install()
# 确保日志目录存在
log_dir = Path(log_config.file_path).parent
log_dir.mkdir(parents=True, exist_ok=True)
# 设置主日志文件 - 带有详细路径信息
logger.add(
log_config.file_path,
level=log_config.level.value,
rotation=log_config.rotation,
retention=log_config.retention,
compression=log_config.compression,
backtrace=log_config.backtrace,
diagnose=log_config.diagnose,
# 自定义格式,显示完整的文件路径和行号
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} | {message}",
)
logger.info("日志系统初始化完成")
def get_logger():
"""获取配置好的logger实例"""
return logger
class LoggerMixin:
"""用户日志混合类"""
user_id: str = ""
trace_id: str = ""
def __init__(self, user_id: str = "", trace_id: str = ""):
self.user_id = user_id
self.trace_id = trace_id
def _build_message(self, message: str):
if self.user_id and self.trace_id:
return f"[{self.user_id}] [{self.trace_id}] {message}"
elif self.user_id:
return f"[{self.user_id}] {message}"
elif self.trace_id:
return f"[{self.trace_id}] {message}"
else:
return message
def _build_alt_message(self, alt: str):
if self.user_id and self.trace_id:
return f"[bold green][{self.user_id}][/bold green] [bold blue][{self.trace_id}][/bold blue] {alt}"
elif self.user_id:
return f"[bold green][{self.user_id}][/bold green] {alt}"
elif self.trace_id:
return f"[bold blue][{self.trace_id}][/bold blue] {alt}"
else:
return alt
def info(self, message: str, alt: str = ""):
logger.opt(depth=1).info(
self._build_message(message),
alt=self._build_alt_message(alt if alt else message),
)
def debug(self, message: str, alt: str = ""):
logger.opt(depth=1).debug(
self._build_message(message),
alt=self._build_alt_message(alt if alt else message),
)
def warning(self, message: str, alt: str = ""):
logger.opt(depth=1).warning(
self._build_message(message),
alt=self._build_alt_message(alt if alt else message),
)
def error(self, message: str, alt: str = ""):
logger.opt(depth=1).error(
self._build_message(message),
alt=self._build_alt_message(alt if alt else message),
)
def success(self, message: str, alt: str = ""):
logger.opt(depth=1).success(
self._build_message(message),
alt=self._build_alt_message(alt if alt else message),
)
def exception(self, e: Exception):
logger.opt(depth=1).exception(e)
def get_user_logger(user_id: str):
return LoggerMixin(user_id)
setup_logger()

173
loveace/config/manager.py Normal file
View File

@@ -0,0 +1,173 @@
import json
from pathlib import Path
from typing import Any, Dict, Optional
from loguru import logger
from pydantic import ValidationError
from loveace.config.settings import Settings
class ConfigManager:
"""配置文件管理器"""
def __init__(self, config_file: str = "config.json"):
self.config_file = Path(config_file)
self._settings: Optional[Settings] = None
self._ensure_config_dir()
def _ensure_config_dir(self):
"""确保配置文件目录存在"""
self.config_file.parent.mkdir(parents=True, exist_ok=True)
def _create_default_config(self) -> Settings:
"""创建默认配置"""
logger.info("正在创建默认配置文件...")
return Settings()
def _save_config(self, settings: Settings):
"""保存配置到文件"""
try:
config_dict = settings.dict()
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump(config_dict, f, indent=2, ensure_ascii=False)
logger.info(f"配置已保存到 {self.config_file}")
except Exception as e:
logger.error(f"保存配置文件失败: {e}")
raise
def _load_config(self) -> Settings:
"""从文件加载配置"""
if not self.config_file.exists():
logger.warning(f"配置文件 {self.config_file} 不存在,将创建默认配置")
settings = self._create_default_config()
self._save_config(settings)
return settings
try:
with open(self.config_file, "r", encoding="utf-8") as f:
config_data = json.load(f)
# 验证并创建Settings对象
settings = Settings(**config_data)
logger.info(f"成功加载配置文件: {self.config_file}")
return settings
except json.JSONDecodeError as e:
logger.error(f"配置文件JSON格式错误: {e}")
raise
except ValidationError as e:
logger.error(f"配置文件验证失败: {e}")
raise
except Exception as e:
logger.error(f"加载配置文件失败: {e}")
raise
def get_settings(self) -> Settings:
"""获取配置设置"""
if self._settings is None:
self._settings = self._load_config()
return self._settings
def reload_config(self) -> Settings:
"""重新加载配置"""
logger.info("正在重新加载配置...")
self._settings = self._load_config()
return self._settings
def update_config(self, **kwargs) -> Settings:
"""更新配置"""
settings = self.get_settings()
# 创建新的配置字典
config_dict = settings.dict()
# 更新指定的配置项
for key, value in kwargs.items():
if "." in key:
# 支持嵌套键,如 'database.url'
keys = key.split(".")
current = config_dict
for k in keys[:-1]:
if k not in current:
current[k] = {}
current = current[k]
current[keys[-1]] = value
else:
config_dict[key] = value
try:
# 验证更新后的配置
new_settings = Settings(**config_dict)
self._save_config(new_settings)
self._settings = new_settings
logger.info("配置更新成功")
return new_settings
except ValidationError as e:
logger.error(f"配置更新失败,验证错误: {e}")
raise
def validate_config(self) -> bool:
"""验证配置完整性"""
try:
settings = self.get_settings()
# 检查关键配置项
issues = []
# 检查数据库配置
if not settings.database.url:
issues.append("数据库URL未配置")
# 检查S3配置如果需要使用
if settings.s3.bucket_name and not settings.s3.access_key_id:
issues.append("S3配置不完整缺少access_key_id")
if settings.s3.bucket_name and not settings.s3.secret_access_key:
issues.append("S3配置不完整缺少secret_access_key")
# 检查日志配置
log_dir = Path(settings.log.file_path).parent
if not log_dir.exists():
try:
log_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"创建日志目录: {log_dir}")
except Exception as e:
issues.append(f"无法创建日志目录 {log_dir}: {e}")
if issues:
logger.warning("配置验证发现问题:")
for issue in issues:
logger.warning(f" - {issue}")
return False
logger.info("配置验证通过")
return True
except Exception as e:
logger.error(f"配置验证失败: {e}")
return False
def get_config_summary(self) -> Dict[str, Any]:
"""获取配置摘要(隐藏敏感信息)"""
settings = self.get_settings()
config_dict = settings.dict()
# 隐藏敏感信息
sensitive_keys = ["database.url", "s3.access_key_id", "s3.secret_access_key"]
def hide_sensitive(data: Dict[str, Any], keys: list, prefix: str = ""):
for key, value in data.items():
current_key = f"{prefix}.{key}" if prefix else key
if current_key in sensitive_keys:
if isinstance(value, str) and value:
data[key] = value[:8] + "..." if len(value) > 8 else "***"
elif isinstance(value, dict):
hide_sensitive(value, keys, current_key)
summary = config_dict.copy()
hide_sensitive(summary, sensitive_keys)
return summary
# 全局配置管理器实例
config_manager = ConfigManager()

194
loveace/config/settings.py Normal file
View File

@@ -0,0 +1,194 @@
from enum import Enum
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator
class LogLevel(str, Enum):
"""日志级别枚举"""
TRACE = "TRACE"
DEBUG = "DEBUG"
INFO = "INFO"
SUCCESS = "SUCCESS"
WARNING = "WARNING"
ERROR = "ERROR"
CRITICAL = "CRITICAL"
class DatabaseConfig(BaseModel):
"""数据库配置"""
url: str = Field(
default="mysql+aiomysql://root:123456@localhost:3306/loveac",
description="数据库连接URL",
)
echo: bool = Field(default=False, description="是否启用SQL日志")
pool_size: int = Field(default=10, description="连接池大小")
max_overflow: int = Field(default=20, description="连接池最大溢出")
pool_timeout: int = Field(default=30, description="连接池超时时间(秒)")
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",
)
room_cache_path: str = Field(
default="data/isim_rooms.json", description="寝室信息缓存路径"
)
room_cache_expire: int = Field(
default=86400, description="寝室信息刷新间隔(秒)"
) # 默认24小时刷新一次
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="默认超时时间(秒)")
max_retries: int = Field(default=3, description="最大重试次数")
max_reconnect_retries: int = Field(default=2, description="最大重连次数")
activity_timeout: int = Field(default=300, description="活动超时时间(秒)")
monitor_interval: int = Field(default=60, description="监控间隔(秒)")
retry_base_delay: float = Field(default=1.0, description="重试基础延迟(秒)")
retry_max_delay: float = Field(default=60.0, description="重试最大延迟(秒)")
retry_exponential_base: float = Field(default=2, description="重试指数基数")
server_url: str = Field(
default="https://vpn.aufe.edu.cn", description="AUFE服务器URL"
)
ec_check_url: str = Field(
default="http://txzx-aufe-edu-cn-s.vpn2.aufe.edu.cn:8118/dzzy/list.htm",
description="EC检查URL",
)
# UAAP配置
uaap_base_url: str = Field(
default="http://uaap-aufe-edu-cn.vpn2.aufe.edu.cn:8118/cas",
description="UAAP基础URL",
)
uaap_login_url: str = Field(
default="http://uaap-aufe-edu-cn.vpn2.aufe.edu.cn:8118/cas/login?service=http%3A%2F%2Fjwcxk2.aufe.edu.cn%2Fj_spring_cas_security_check",
description="UAAP登录URL",
)
uaap_check_url: str = Field(
default="http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/",
description="UAAP检查链接",
)
# 默认请求头
default_headers: Dict[str, str] = Field(
default_factory=lambda: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
},
description="默认请求头",
)
class RedisConfig(BaseModel):
"""Redis客户端配置"""
host: str = Field(default="localhost", description="Redis主机地址")
port: int = Field(default=6379, description="Redis端口")
db: int = Field(default=0, description="Redis数据库编号")
password: Optional[str] = Field(default=None, description="Redis密码")
encoding: str = Field(default="utf-8", description="字符编码")
decode_responses: bool = Field(default=True, description="是否自动解码响应")
max_connections: int = Field(default=50, description="连接池最大连接数")
socket_keepalive: bool = Field(default=True, description="是否启用socket保活")
socket_keepalive_options: Optional[Dict[str, Any]] = Field(
default=None, description="Socket保活选项"
)
health_check_interval: int = Field(default=30, description="健康检查间隔(秒)")
retry_on_timeout: bool = Field(default=True, description="超时时是否重试")
class S3Config(BaseModel):
"""S3客户端配置"""
access_key_id: str = Field(default="", description="S3访问密钥ID")
secret_access_key: str = Field(default="", description="S3秘密访问密钥")
endpoint_url: str = Field(default="", description="S3终端节点URL")
region_name: str = Field(default="us-east-1", description="S3区域名称")
bucket_name: str = Field(default="", description="默认存储桶名称")
use_ssl: bool = Field(default=True, description="是否使用SSL")
signature_version: str = Field(default="s3v4", description="签名版本")
addressing_style: str = Field(
default="auto", description="地址风格auto, path, virtual"
)
@field_validator("access_key_id", "secret_access_key", "bucket_name")
@classmethod
def validate_required_fields(cls, v):
"""验证必填字段"""
# 允许为空,但应在运行时检查
return v
class LogConfig(BaseModel):
"""日志配置"""
level: LogLevel = Field(default=LogLevel.INFO, description="日志级别")
file_path: str = Field(default="logs/app.log", description="日志文件路径")
rotation: str = Field(default="10 MB", description="日志轮转大小")
retention: str = Field(default="30 days", description="日志保留时间")
compression: str = Field(default="zip", description="日志压缩格式")
backtrace: bool = Field(default=True, description="是否启用回溯")
diagnose: bool = Field(default=True, description="是否启用诊断")
console_output: bool = Field(default=True, description="是否输出到控制台")
class AppConfig(BaseModel):
"""应用程序配置"""
title: str = Field(default="LoveACE API", description="应用标题")
description: str = Field(default="LoveACE API", description="应用描述")
version: str = Field(default="1.0.0", description="应用版本")
debug: bool = Field(default=False, description="是否启用调试模式")
# CORS配置
cors_allow_origins: List[str] = Field(
default_factory=lambda: ["*"], description="允许的CORS来源"
)
cors_allow_credentials: bool = Field(default=True, description="是否允许CORS凭据")
cors_allow_methods: List[str] = Field(
default_factory=lambda: ["*"], description="允许的CORS方法"
)
cors_allow_headers: List[str] = Field(
default_factory=lambda: ["*"], description="允许的CORS头部"
)
# 服务器配置
host: str = Field(default="0.0.0.0", description="服务器主机")
port: int = Field(default=8000, description="服务器端口")
workers: int = Field(default=1, description="工作进程数")
# 安全配置
rsa_private_key_path: str = Field(
default="private_key.hex", description="RSA私钥路径"
)
rsa_protect_key_path: str = Field(
default="data/keys/", description="RSA保护密钥存储路径"
)
class Settings(BaseModel):
"""主配置类"""
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
redis: RedisConfig = Field(default_factory=RedisConfig)
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)
class Config:
json_encoders = {
# 为枚举类型提供JSON编码器
LogLevel: lambda v: v.value
}

View File

@@ -0,0 +1,14 @@
import datetime
from sqlalchemy import String, func
from sqlalchemy.orm import Mapped, mapped_column
from loveace.database.base import Base
class AACTicket(Base):
__tablename__ = "aac_ticket_table"
id: Mapped[int] = mapped_column(primary_key=True)
userid: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
aac_token: Mapped[str] = mapped_column(String(1024), nullable=False)
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())

View File

@@ -0,0 +1,13 @@
import datetime
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from loveace.database.base import Base
class LoginCoolDown(Base):
__tablename__ = "login_cooldown_table"
id: Mapped[int] = mapped_column(primary_key=True)
userid: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
expire_date: Mapped[datetime.datetime] = mapped_column(nullable=False)

View File

@@ -0,0 +1,20 @@
import datetime
from sqlalchemy import String, func
from sqlalchemy.orm import Mapped, mapped_column
from loveace.database.base import Base
class InviteCode(Base):
__tablename__ = "invite_code_table"
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
class RegisterCoolDown(Base):
__tablename__ = "register_cooldown_table"
id: Mapped[int] = mapped_column(primary_key=True)
userid: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
expire_date: Mapped[datetime.datetime] = mapped_column(nullable=False)

View File

@@ -0,0 +1,15 @@
import datetime
from sqlalchemy import String, func
from sqlalchemy.orm import Mapped, mapped_column
from loveace.database.base import Base
class AuthMEToken(Base):
__tablename__ = "auth_me_token_table"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[str] = mapped_column(String(20), nullable=False)
token: Mapped[str] = mapped_column(String(256), unique=True, nullable=False)
device_id: Mapped[str] = mapped_column(String(256), nullable=False)
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())

View File

@@ -0,0 +1,16 @@
import datetime
from sqlalchemy import String, func
from sqlalchemy.orm import Mapped, mapped_column
from loveace.database.base import Base
class ACEUser(Base):
__tablename__ = "ace_user_table"
id: Mapped[int] = mapped_column(primary_key=True)
userid: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
password: Mapped[str] = mapped_column(String(2048), nullable=True)
ec_password: Mapped[str] = mapped_column(String(2048), nullable=True)
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
last_login_date: Mapped[datetime.datetime] = mapped_column(nullable=True)

View File

@@ -0,0 +1,6 @@
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase
class Base(AsyncAttrs, DeclarativeBase):
pass

149
loveace/database/creator.py Normal file
View File

@@ -0,0 +1,149 @@
from typing import AsyncGenerator
import redis.asyncio as aioredis
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from loveace.config.logger import logger
from loveace.config.manager import config_manager
from loveace.database.base import Base
class DatabaseManager:
"""数据库管理器,负责数据库连接和会话管理"""
def __init__(self):
self.engine = None
self.async_session_maker = None
self._config = None
self.redis_client = None
self._redis_config = None
def _get_db_config(self):
"""获取数据库配置"""
if self._config is None:
self._config = config_manager.get_settings().database
return self._config
def _get_redis_config(self):
"""获取Redis配置"""
if self._redis_config is None:
self._redis_config = config_manager.get_settings().redis
return self._redis_config
async def init_db(self) -> bool:
"""初始化数据库连接"""
db_config = self._get_db_config()
logger.info("正在初始化数据库连接...")
try:
self.engine = create_async_engine(
db_config.url,
echo=db_config.echo,
pool_size=db_config.pool_size,
max_overflow=db_config.max_overflow,
pool_timeout=db_config.pool_timeout,
pool_recycle=db_config.pool_recycle,
future=True,
)
self.async_session_maker = async_sessionmaker(
self.engine, class_=AsyncSession, expire_on_commit=False
)
# 创建所有表
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
except Exception as e:
logger.error(f"数据库连接初始化失败: {e}")
logger.error(f"数据库连接URL: {db_config.url}")
db_config.url = "****"
logger.error(f"数据库连接配置: {db_config}")
logger.error("请启动config_tui.py来配置数据库连接")
return False
logger.info("数据库连接初始化完成")
return True
async def close_db(self):
"""关闭数据库连接"""
if self.engine:
logger.info("正在关闭数据库连接...")
await self.engine.dispose()
logger.info("数据库连接已关闭")
async def get_redis_client(self) -> aioredis.Redis:
"""获取Redis客户端
Returns:
Redis客户端实例
Raises:
RuntimeError: 如果Redis初始化失败
"""
if self.redis_client is None:
success = await self._init_redis()
if not success:
raise RuntimeError(
"Failed to initialize Redis client. Check logs for details."
)
return self.redis_client # type: ignore
async def _init_redis(self) -> bool:
"""初始化Redis连接"""
redis_config = self._get_redis_config()
logger.info("正在初始化Redis连接...")
try:
self.redis_client = aioredis.Redis(
host=redis_config.host,
port=redis_config.port,
db=redis_config.db,
password=redis_config.password,
encoding=redis_config.encoding,
decode_responses=redis_config.decode_responses,
max_connections=redis_config.max_connections,
socket_keepalive=redis_config.socket_keepalive,
)
# 测试连接
await self.redis_client.ping()
logger.info("Redis连接初始化完成")
return True
except Exception as e:
logger.error(f"Redis连接初始化失败: {e}")
logger.error(
f"Redis配置: host={redis_config.host}, port={redis_config.port}, db={redis_config.db}"
)
return False
async def close_redis(self):
"""关闭Redis连接"""
if self.redis_client:
logger.info("正在关闭Redis连接...")
await self.redis_client.close()
self.redis_client = None
logger.info("Redis连接已关闭")
async def get_session(self) -> AsyncGenerator[AsyncSession, None]:
"""获取数据库会话"""
if not self.async_session_maker:
raise RuntimeError("Database not initialized. Call init_db() first.")
async with self.async_session_maker() as session:
try:
yield session
finally:
await session.close()
# 全局数据库管理器实例
db_manager = DatabaseManager()
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
"""获取数据库会话的依赖函数用于FastAPI路由"""
async for session in db_manager.get_session():
yield session
async def get_redis_instance() -> aioredis.Redis:
"""获取Redis实例的依赖函数用于FastAPI路由"""
return await db_manager.get_redis_client()

View File

@@ -0,0 +1,15 @@
import datetime
from sqlalchemy import String, func
from sqlalchemy.orm import Mapped, mapped_column
from loveace.database.base import Base
class RoomBind(Base):
__tablename__ = "isim_room_bind_table"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[str] = mapped_column(String(20), nullable=False)
roomid: Mapped[str] = mapped_column(String(20), nullable=False)
roomtext: Mapped[str] = mapped_column(String(50), nullable=False)
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())

View File

@@ -0,0 +1 @@
# 劳动俱乐部数据库模型

View File

@@ -0,0 +1,14 @@
import datetime
from sqlalchemy import String, func
from sqlalchemy.orm import Mapped, mapped_column
from loveace.database.base import Base
class LDJLBTicket(Base):
__tablename__ = "ldjlb_ticket_table"
id: Mapped[int] = mapped_column(primary_key=True)
userid: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
ldjlb_token: Mapped[str] = mapped_column(String(1024), nullable=False)
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())

View File

@@ -0,0 +1,26 @@
import datetime
from sqlalchemy import String, func
from sqlalchemy.orm import Mapped, mapped_column
from loveace.database.base import Base
class FlutterThemeProfile(Base):
__tablename__ = "flutter_theme_profile"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(nullable=False, unique=True)
dark_mode: Mapped[bool] = mapped_column(nullable=False, default=False)
light_mode_opacity: Mapped[float] = mapped_column(nullable=False, default=1.0)
light_mode_brightness: Mapped[float] = mapped_column(nullable=False, default=1.0)
light_mode_background_url: Mapped[str] = mapped_column(String(300), nullable=True)
light_mode_background_md5: Mapped[str] = mapped_column(String(128), nullable=True)
light_mode_blur: Mapped[float] = mapped_column(nullable=False, default=0.0)
dark_mode_opacity: Mapped[float] = mapped_column(nullable=False, default=1.0)
dark_mode_brightness: Mapped[float] = mapped_column(nullable=False, default=1.0)
dark_mode_background_url: Mapped[str] = mapped_column(String(300), nullable=True)
dark_mode_background_md5: Mapped[str] = mapped_column(String(128), nullable=True)
dark_mode_background_blur: Mapped[float] = mapped_column(
nullable=False, default=0.0
)
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())

View File

@@ -0,0 +1,17 @@
import datetime
from sqlalchemy import String, func
from sqlalchemy.orm import Mapped, mapped_column
from loveace.database.base import Base
class UserProfile(Base):
__tablename__ = "ace_user_profile"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[str] = mapped_column(String(20), nullable=False)
nickname: Mapped[str] = mapped_column(String(50), nullable=False)
slogan: Mapped[str] = mapped_column(String(100), nullable=True)
avatar_url: Mapped[str] = mapped_column(String(200), nullable=True)
avatar_md5: Mapped[str] = mapped_column(String(128), nullable=True)
create_date: Mapped[datetime.datetime] = mapped_column(server_default=func.now())

View File

@@ -0,0 +1,24 @@
import time
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from loveace.config.logger import logger
class ProcessTimeMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start_time = time.time()
logger.info(
f"{request.method} {request.url.path} START",
f"[Bold White][{request.method}][/Bold White] {request.url.path} [Bold Green]START[/Bold Green]",
)
response: Response = await call_next(request)
process_time = time.time() - start_time
response.headers["X-Process-Time"] = str(process_time)
logger.info(
f"{request.method} {request.url.path} END ({process_time:.4f}s)",
f"[Bold White][{request.method}][/Bold White] {request.url.path} [Bold Green]END[/Bold Green] [Dim]({process_time:.4f}s)[/Dim]",
)
return response

View File

@@ -0,0 +1,13 @@
"""Router dependencies"""
from loveace.router.dependencies.auth import get_user_by_token
from loveace.router.dependencies.logger import (
logger_mixin_with_user,
no_user_logger_mixin,
)
__all__ = [
"no_user_logger_mixin",
"logger_mixin_with_user",
"get_user_by_token",
]

View File

@@ -0,0 +1,55 @@
from typing import Annotated
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from loveace.database.auth.token import AuthMEToken
from loveace.database.auth.user import ACEUser
from loveace.database.creator import get_db_session
from loveace.router.dependencies.logger import LoggerMixin, no_user_logger_mixin
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.exception import UniResponseHTTPException
auth_scheme = HTTPBearer(auto_error=False)
async def get_user_by_token(
authorization: Annotated[
HTTPAuthorizationCredentials | None, Depends(auth_scheme)
] = None,
db_session: AsyncSession = Depends(get_db_session),
logger: LoggerMixin = Depends(no_user_logger_mixin),
) -> ACEUser:
"""通过Token获取用户"""
if not authorization:
logger.error("缺少认证令牌")
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
logger.trace_id
)
token = authorization.credentials
try:
async with db_session as session:
query = select(AuthMEToken).where(AuthMEToken.token == token)
result = await session.execute(query)
user_token = result.scalars().first()
if user_token is None:
logger.error("无效的认证令牌")
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
logger.trace_id
)
query = select(ACEUser).where(ACEUser.userid == user_token.user_id)
result = await session.execute(query)
user = result.scalars().first()
if user is None:
logger.error("用户不存在")
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
logger.trace_id
)
return user
except (HTTPException, UniResponseHTTPException):
raise
except Exception as e:
logger.exception(e)
raise ProtectRouterErrorToCode().server_error.to_http_exception(logger.trace_id)

View File

@@ -0,0 +1,11 @@
import uuid
from loveace.config.logger import LoggerMixin
def no_user_logger_mixin() -> LoggerMixin:
return LoggerMixin(trace_id=str(uuid.uuid4().hex))
def logger_mixin_with_user(userid: str) -> LoggerMixin:
return LoggerMixin(trace_id=str(uuid.uuid4().hex), user_id=userid)

View File

@@ -0,0 +1,10 @@
from fastapi import APIRouter
from loveace.router.endpoint.aac.credit import aac_credit_router
aac_base_router = APIRouter(
prefix="/aac",
tags=["爱安财"],
)
aac_base_router.include_router(aac_credit_router)

View File

@@ -0,0 +1,185 @@
from typing import List
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from httpx import Headers, HTTPError
from pydantic import ValidationError
from loveace.router.endpoint.aac.model.base import AACConfig
from loveace.router.endpoint.aac.model.credit import (
LoveACCreditCategory,
LoveACCreditInfo,
)
from loveace.router.endpoint.aac.utils.aac_ticket import get_aac_header
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
aac_credit_router = APIRouter(
prefix="/credit",
responses=ProtectRouterErrorToCode().gen_code_table(),
)
ENDPOINT = {
"total_score": "/User/Center/DoGetScoreInfo?sf_request_type=ajax",
"score_list": "/User/Center/DoGetScoreList?sf_request_type=ajax",
}
@aac_credit_router.get(
"/info",
response_model=UniResponseModel[LoveACCreditInfo],
summary="获取爱安财总分信息",
)
async def get_credit_info(
conn: AUFEConnection = Depends(get_aufe_conn),
headers: Headers = Depends(get_aac_header),
) -> UniResponseModel[LoveACCreditInfo] | JSONResponse:
"""
获取用户的爱安财总分信息
✅ 功能特性:
- 获取爱安财总分和毕业要求状态
- 获取未达标的原因说明
- 实时从 AUFE 服务获取最新数据
💡 使用场景:
- 个人中心显示爱安财总分
- 检查是否满足毕业要求
- 了解分数不足的原因
Returns:
LoveACCreditInfo: 包含总分、达成状态和详细信息
"""
try:
conn.logger.info("开始获取爱安财总分信息")
response = await conn.client.post(
url=AACConfig().to_full_url(ENDPOINT["total_score"]),
data={},
headers=headers,
timeout=6000,
)
if response.status_code != 200:
conn.logger.error(
f"获取爱安财总分信息失败HTTP状态码: {response.status_code}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取爱安财总分信息失败,请稍后重试"
)
data = response.json()
if data.get("code") != 0:
conn.logger.error(f"获取爱安财总分信息失败,响应代码: {data.get('code')}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取爱安财总分信息失败,请稍后重试"
)
data = data.get("data", {})
if not data:
conn.logger.error("获取爱安财总分信息失败,响应数据为空")
return ProtectRouterErrorToCode().null_response.to_json_response(
conn.logger.trace_id, "获取爱安财总分信息失败,请稍后重试"
)
try:
credit_info = LoveACCreditInfo.model_validate(data)
conn.logger.info("成功获取爱安财总分信息")
return UniResponseModel[LoveACCreditInfo](
success=True,
data=credit_info,
message="获取爱安财总分信息成功",
error=None,
)
except ValidationError as ve:
conn.logger.error(f"解析爱安财总分信息失败: {str(ve)}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id, "解析爱安财总分信息失败,请稍后重试"
)
except HTTPError as he:
conn.logger.error(f"获取爱安财总分信息异常: {str(he)}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取爱安财总分信息异常,请稍后重试"
)
except Exception as e:
conn.logger.error(f"获取爱安财总分信息未知异常: {str(e)}")
return ProtectRouterErrorToCode().unknown_error.to_json_response(
conn.logger.trace_id, "获取爱安财总分信息未知异常,请稍后重试"
)
@aac_credit_router.get(
"/list",
response_model=UniResponseModel[List[LoveACCreditCategory]],
summary="获取爱安财分数明细",
)
async def get_credit_list(
conn: AUFEConnection = Depends(get_aufe_conn),
headers: Headers = Depends(get_aac_header),
) -> UniResponseModel[List[LoveACCreditCategory]] | JSONResponse:
"""
获取用户的爱安财分数明细列表
✅ 功能特性:
- 获取分数的详细分类信息
- 显示每个分数项的具体内容
- 支持分页查询
💡 使用场景:
- 查看分数明细页面
- 了解各类别分数构成
- 分析分数不足的原因
Returns:
list[LoveACCreditCategory]: 分数分类列表,每个分类包含多个分数项
"""
try:
conn.logger.info("开始获取爱安财分数明细")
response = await conn.client.post(
url=AACConfig().to_full_url(ENDPOINT["score_list"]),
data={"pageIndex": "1", "pageSize": "10"},
headers=headers,
timeout=6000,
)
if response.status_code != 200:
conn.logger.error(
f"获取爱安财分数明细失败HTTP状态码: {response.status_code}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取爱安财分数明细失败,请稍后重试"
)
data = response.json()
if data.get("code") != 0:
conn.logger.error(f"获取爱安财分数明细失败,响应代码: {data.get('code')}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取爱安财分数明细失败,请稍后重试"
)
data = data.get("data", [])
if not data:
conn.logger.error("获取爱安财分数明细失败,响应数据为空")
return ProtectRouterErrorToCode().null_response.to_json_response(
conn.logger.trace_id, "获取爱安财分数明细失败,请稍后重试"
)
try:
credit_list = [LoveACCreditCategory.model_validate(item) for item in data]
conn.logger.info("成功获取爱安财分数明细")
return UniResponseModel[List[LoveACCreditCategory]](
success=True,
data=credit_list,
message="获取爱安财分数明细成功",
error=None,
)
except ValidationError as ve:
conn.logger.error(f"解析爱安财分数明细失败: {str(ve)}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id, "解析爱安财分数明细失败,请稍后重试"
)
except HTTPError as he:
conn.logger.error(f"获取爱安财分数明细异常: {str(he)}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取爱安财分数明细异常,请稍后重试"
)
except Exception as e:
conn.logger.error(f"获取爱安财分数明细未知异常: {str(e)}")
return ProtectRouterErrorToCode().unknown_error.to_json_response(
conn.logger.trace_id, "获取爱安财分数明细未知异常,请稍后重试"
)

View File

@@ -0,0 +1,22 @@
from pathlib import Path
from loveace.config.manager import config_manager
settings = config_manager.get_settings()
class AACConfig:
"""AAC 模块配置常量"""
BASE_URL = "http://api-dekt-ac-acxk-net.vpn2.aufe.edu.cn:8118"
WEB_URL = "http://dekt-ac-acxk-net.vpn2.aufe.edu.cn:8118"
LOGIN_SERVICE_URL = "http://uaap-aufe-edu-cn.vpn2.aufe.edu.cn:8118/cas/login?service=http%3a%2f%2fapi.dekt.ac.acxk.net%2fUser%2fIndex%2fCoreLoginCallback%3fisCASGateway%3dtrue"
RSA_PRIVATE_KEY_PATH = str(
Path(settings.app.rsa_protect_key_path).joinpath("aac_private_key.pem")
)
def to_full_url(self, path: str) -> str:
"""将路径转换为完整URL"""
if path.startswith("http://") or path.startswith("https://"):
return path
return self.BASE_URL.rstrip("/") + "/" + path.lstrip("/")

View File

@@ -0,0 +1,40 @@
from typing import List
from pydantic import BaseModel, Field
class LoveACCreditInfo(BaseModel):
"""爱安财总分信息"""
total_score: float = Field(
0.0, alias="TotalScore", description="总分,爱安财服务端已四舍五入"
)
is_type_adopt: bool = Field(
False, alias="IsTypeAdopt", description="是否达到毕业要求"
)
type_adopt_result: str = Field(
"", alias="TypeAdoptResult", description="未达到毕业要求的原因"
)
class LoveACCreditItem(BaseModel):
"""爱安财分数明细条目"""
id: str = Field("", alias="ID", description="条目ID")
title: str = Field("", alias="Title", description="条目标题")
type_name: str = Field("", alias="TypeName", description="条目类别名称")
user_no: str = Field("", alias="UserNo", description="用户编号,即学号")
score: float = Field(0.0, alias="Score", description="分数")
add_time: str = Field("", alias="AddTime", description="添加时间")
class LoveACCreditCategory(BaseModel):
"""爱安财分数类别"""
id: str = Field("", alias="ID", description="类别ID")
show_num: int = Field(0, alias="ShowNum", description="显示序号")
type_name: str = Field("", alias="TypeName", description="类别名称")
total_score: float = Field(0.0, alias="TotalScore", description="类别总分")
children: List[LoveACCreditItem] = Field(
[], alias="children", description="该类别下的分数明细列表"
)

View File

@@ -0,0 +1,167 @@
from urllib.parse import unquote
from fastapi import Depends
from httpx import Headers
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from loveace.config.manager import config_manager
from loveace.database.aac.ticket import AACTicket
from loveace.database.creator import get_db_session
from loveace.router.dependencies.auth import ProtectRouterErrorToCode
from loveace.router.endpoint.aac.model.base import AACConfig
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
from loveace.utils.rsa import RSAUtils
rsa = RSAUtils.get_or_create_rsa_utils(AACConfig.RSA_PRIVATE_KEY_PATH)
def _extract_and_encrypt_token(location: str, logger) -> str | None:
"""从重定向URL中提取并加密系统令牌"""
try:
sys_token = location.split("ticket=")[-1]
# URL编码转为正常字符串
sys_token = unquote(sys_token)
if not sys_token:
logger.error("系统令牌为空")
return None
logger.info(f"获取到系统令牌: {sys_token[:10]}...")
# 加密系统令牌
encrypted_token = rsa.encrypt(sys_token)
return encrypted_token
except Exception as e:
logger.error(f"解析/加密系统令牌失败: {str(e)}")
return None
async def get_system_token(conn: AUFEConnection) -> str:
next_location = AACConfig.LOGIN_SERVICE_URL
max_redirects = 10 # 防止无限重定向
redirect_count = 0
try:
while redirect_count < max_redirects:
response = await conn.client.get(
next_location, follow_redirects=False, timeout=conn.timeout
)
# 如果是重定向,继续跟踪
if response.status_code in (301, 302, 303, 307, 308):
next_location = response.headers.get("Location")
if not next_location:
conn.logger.error("重定向响应中缺少 Location 头")
return ""
conn.logger.debug(f"重定向到: {next_location}")
redirect_count += 1
if "register?ticket=" in next_location:
conn.logger.info(f"重定向到爱安财注册页面: {next_location}")
encrypted_token = _extract_and_encrypt_token(
next_location, conn.logger
)
return encrypted_token if encrypted_token else ""
else:
break
if redirect_count >= max_redirects:
conn.logger.error(f"重定向次数过多 ({max_redirects})")
return ""
conn.logger.error("未能获取系统令牌")
return ""
except Exception as e:
conn.logger.error(f"获取系统令牌异常: {str(e)}")
return ""
async def get_aac_header(
conn: AUFEConnection = Depends(get_aufe_conn),
db: AsyncSession = Depends(get_db_session),
) -> Headers:
"""
获取AAC Ticket的依赖项。
如果用户没有登录AUFE或UAAP或者AAC Ticket不存在且无法获取新的Ticket则会抛出HTTP异常。
否则返回有效的AAC Ticket字符串。
"""
# 检查AAC Ticket是否存在
async with db as session:
result = await session.execute(
select(AACTicket).where(AACTicket.userid == conn.userid)
)
aac_ticket = result.scalars().first()
if not aac_ticket:
aac_ticket = await _get_or_fetch_ticket(conn, db, is_new=True)
else:
aac_ticket_token = aac_ticket.aac_token
try:
# 解密以验证Ticket有效性
decrypted_ticket = rsa.decrypt(aac_ticket_token)
if not decrypted_ticket:
raise ValueError("解密后的Ticket为空")
aac_ticket = decrypted_ticket
except Exception as e:
conn.logger.error(
f"用户 {conn.userid} 的 AAC Ticket 无效,正在获取新的 Ticket: {str(e)}"
)
aac_ticket = await _get_or_fetch_ticket(conn, db, is_new=False)
else:
conn.logger.info(f"用户 {conn.userid} 使用现有的 AAC Ticket")
return Headers(
{
**config_manager.get_settings().aufe.default_headers,
"ticket": aac_ticket,
"sdp-app-session": conn.twf_id,
}
)
async def _get_or_fetch_ticket(
conn: AUFEConnection, db: AsyncSession, is_new: bool
) -> str:
"""获取或重新获取AAC Ticket并保存到数据库返回解密后的ticket"""
action_type = "获取" if is_new else "重新获取"
conn.logger.info(
f"用户 {conn.userid} 的 AAC Ticket {'不存在' if is_new else '无效'},正在{action_type}新的 Ticket"
)
encrypted_token = await get_system_token(conn)
if not encrypted_token:
conn.logger.error(f"用户 {conn.userid} {action_type} AAC Ticket 失败")
raise ProtectRouterErrorToCode().remote_service_error.to_http_exception(
conn.logger.trace_id,
message="获取 AAC Ticket 失败,请检查 AUFE/UAAP 登录状态",
)
# 解密token
try:
decrypted_token = rsa.decrypt(encrypted_token)
if not decrypted_token:
raise ValueError("解密后的Ticket为空")
except Exception as e:
conn.logger.error(f"用户 {conn.userid} 解密 AAC Ticket 失败: {str(e)}")
raise ProtectRouterErrorToCode().remote_service_error.to_http_exception(
conn.logger.trace_id,
message="解密 AAC Ticket 失败",
)
# 保存加密后的token到数据库
async with db as session:
if is_new:
session.add(AACTicket(userid=conn.userid, aac_token=encrypted_token))
else:
result = await session.execute(
select(AACTicket).where(AACTicket.userid == conn.userid)
)
existing_ticket = result.scalars().first()
if existing_ticket:
existing_ticket.aac_token = encrypted_token
await session.commit()
conn.logger.success(f"用户 {conn.userid} 成功{action_type}并保存新的 AAC Ticket")
# 返回解密后的token
return decrypted_token

View File

@@ -0,0 +1,30 @@
from fastapi import APIRouter
from fastapi.responses import RedirectResponse
apifox_router = APIRouter()
@apifox_router.get(
"/",
tags=["首页"],
summary="首页 - 请求后跳转到 Apifox 文档页面",
response_model=None,
responses={"307": {"description": "重定向到 Apifox 文档页面"}},
)
async def redirect_to_apifox():
"""
重定向到 API 文档页面
✅ 功能特性:
- 自动重定向到 Apifox 文档
- 提供 API 接口的完整文档
- 包含参数说明和示例
💡 使用场景:
- 访问 API 根路径时自动跳转
- 获取 API 文档
Returns:
RedirectResponse: 重定向到 Apifox 文档页面
"""
return RedirectResponse(url="https://docs.loveace.linota.cn/")

View File

@@ -0,0 +1,10 @@
from fastapi import APIRouter
from loveace.router.endpoint.auth.authme import authme_router
from loveace.router.endpoint.auth.login import login_router
from loveace.router.endpoint.auth.register import register_router
auth_router = APIRouter(prefix="/auth", tags=["用户验证"])
auth_router.include_router(login_router)
auth_router.include_router(register_router)
auth_router.include_router(authme_router)

View File

@@ -0,0 +1,45 @@
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from loveace.router.endpoint.auth.model.authme import AuthMeResponse
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
authme_router = APIRouter(
prefix="/authme", responses=ProtectRouterErrorToCode.gen_code_table()
)
@authme_router.get(
"/token",
response_model=UniResponseModel[AuthMeResponse],
summary="Token 有效性验证",
)
async def auth_me(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[AuthMeResponse] | JSONResponse:
"""
验证 Token 有效性并获取用户信息
✅ 功能特性:
- 验证 Authme Token 是否有效
- 返回当前认证用户的 ID
- 用于前端权限验证
💡 使用场景:
- 前端页面加载时验证登录状态
- Token 过期检测
- 获取当前登录用户信息
Returns:
AuthMeResponse: 包含验证结果和用户 ID
"""
user_id = conn.userid
return UniResponseModel[AuthMeResponse](
success=True,
data=AuthMeResponse(success=True, userid=user_id),
message="Token 验证成功",
error=None,
)

View File

@@ -0,0 +1,222 @@
import secrets
from datetime import datetime, timedelta
from uuid import uuid4
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from loveace.config.logger import LoggerMixin
from loveace.database.auth.login import LoginCoolDown
from loveace.database.auth.token import AuthMEToken
from loveace.database.auth.user import ACEUser
from loveace.database.creator import get_db_session
from loveace.router.dependencies.logger import no_user_logger_mixin
from loveace.router.endpoint.auth.model.login import (
LoginErrorToCode,
LoginRequest,
LoginResponse,
)
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEService
from loveace.service.remote.aufe.depends import get_aufe_service
from loveace.utils.rsa import RSAUtils
login_router = APIRouter(prefix="/login", responses=LoginErrorToCode.gen_code_table())
rsa_util = RSAUtils.get_or_create_rsa_utils()
@login_router.post(
"/next",
response_model=UniResponseModel[LoginResponse],
summary="用户登录",
)
async def login(
login_request: LoginRequest,
db: AsyncSession = Depends(get_db_session),
aufe_service: AUFEService = Depends(get_aufe_service),
logger: LoggerMixin = Depends(no_user_logger_mixin),
) -> UniResponseModel[LoginResponse] | JSONResponse:
"""
用户登录,返回 Authme Token
✅ 功能特性:
- 通过 AUFE 服务验证 EC 密码和登录密码
- 限制用户总 Token 数为 5 个
- 登录失败后设置 1 分钟冷却时间
⚠️ 限制条件:
- 连续登录失败会触发冷却机制
- 冷却期间内拒绝该用户的登录请求
💡 使用场景:
- 用户首次登录
- 用户重新登录(更换设备)
- 用户忘记密码后重新设置并登录
Args:
login_request: 包含用户 ID、EC 密码、登录密码的登录请求
db: 数据库会话
aufe_service: AUFE 远程认证服务
logger: 日志记录器
Returns:
LoginResponse: 包含新生成的 Authme Token
"""
try:
async with db as session:
logger.info(f"用户登录: {login_request.userid}")
# 检查用户是否存在
query = select(ACEUser).where(ACEUser.userid == login_request.userid)
result = await session.execute(query)
user = result.scalars().first()
if user is None:
logger.info(f"用户不存在: {login_request.userid}")
return LoginErrorToCode().invalid_credentials.to_json_response(
logger.trace_id
)
# 检查是否在冷却时间内
query = select(LoginCoolDown).where(LoginCoolDown.userid == user.userid)
result = await session.execute(query)
cooldown = result.scalars().first()
if cooldown and cooldown.expire_date > datetime.now():
logger.info(f"用户 {login_request.userid} 在冷却时间内,拒绝登录")
return LoginErrorToCode().cooldown.to_json_response(logger.trace_id)
# 解密数据库中的 EC密码 登录密码 和 请求体中的 EC密码 登录密码
try:
db_ec_password = rsa_util.decrypt(user.ec_password)
db_password = rsa_util.decrypt(user.password)
ec_password = rsa_util.decrypt(login_request.ec_password)
password = rsa_util.decrypt(login_request.password)
except Exception as e:
logger.info(f"用户 {login_request.userid} 提供的密码解密失败: {e}")
return LoginErrorToCode().invalid_credentials.to_json_response(
logger.trace_id
)
# 尝试使用AUFE服务验证EC密码和登录密码
conn = await aufe_service.get_or_create_connection(
userid=login_request.userid,
ec_password=ec_password,
password=password,
)
if not await conn.health_check():
logger.info(f"用户 {login_request.userid} 的AUFE连接不可用")
# EC密码登录重试机制 (最多3次)
ec_login_status = None
for ec_retry in range(3):
ec_login_status = await conn.ec_login()
if ec_login_status.success:
break
# 如果是攻击防范或密码错误,直接退出重试
if (
ec_login_status.fail_maybe_attacked
or ec_login_status.fail_invalid_credentials
):
logger.info(
f"用户 {login_request.userid} EC登录失败 (攻击防范或密码错误),停止重试"
)
break
logger.info(
f"用户 {login_request.userid} EC登录重试第 {ec_retry + 1}"
)
if not ec_login_status or not ec_login_status.success:
logger.info(f"用户 {login_request.userid} 的EC密码错误")
# 设置冷却时间
cooldown_time = timedelta(minutes=1)
if cooldown:
cooldown.expire_date = datetime.now() + cooldown_time
else:
cooldown = LoginCoolDown(
userid=user.userid,
expire_date=datetime.now() + cooldown_time,
)
session.add(cooldown)
await session.commit()
return (
LoginErrorToCode().remote_invalid_credentials.to_json_response(
logger.trace_id
)
)
# UAAP密码登录重试机制 (最多3次)
uaap_login_status = None
for uaap_retry in range(3):
uaap_login_status = await conn.uaap_login()
if uaap_login_status.success:
break
# 如果是密码错误,直接退出重试
if uaap_login_status.fail_invalid_credentials:
logger.info(
f"用户 {login_request.userid} UAAP登录失败 (密码错误),停止重试"
)
break
logger.info(
f"用户 {login_request.userid} UAAP登录重试第 {uaap_retry + 1}"
)
if not uaap_login_status or not uaap_login_status.success:
logger.info(f"用户 {login_request.userid} 的登录密码错误")
# 设置冷却时间
cooldown_time = timedelta(minutes=1)
if cooldown:
cooldown.expire_date = datetime.now() + cooldown_time
else:
cooldown = LoginCoolDown(
userid=user.userid,
expire_date=datetime.now() + cooldown_time,
)
session.add(cooldown)
await session.commit()
return (
LoginErrorToCode().remote_invalid_credentials.to_json_response(
logger.trace_id
)
)
# 删除冷却时间
if cooldown:
await session.delete(cooldown)
await session.commit()
# 比对密码,如果新的密码与数据库中的密码不一致,则更新数据库中的密码
if db_ec_password != ec_password or db_password != password:
user.ec_password = rsa_util.encrypt(ec_password)
user.password = rsa_util.encrypt(password)
session.add(user)
await session.commit()
logger.info(f"用户 {login_request.userid} 的密码已更新")
# 创建新的Authme Token
new_token = AuthMEToken(
user_id=user.userid,
token=secrets.token_urlsafe(32),
device_id=uuid4().hex,
)
session.add(new_token)
await session.commit()
# 限制用户总 Token 数为5个删除最早的 Token
query = (
select(AuthMEToken)
.where(AuthMEToken.user_id == user.userid)
.order_by(AuthMEToken.create_date.asc())
)
result = await session.execute(query)
tokens = result.scalars().all()
if len(tokens) > 5:
for token in tokens[:-5]:
await session.delete(token)
await session.commit()
logger.info(f"用户 {login_request.userid} 登录成功返回Token")
return UniResponseModel[LoginResponse](
success=True,
data=LoginResponse(token=new_token.token),
message="登录成功",
error=None,
)
except Exception as e:
logger.error(f"用户 {login_request.userid} 登录时发生错误: {e}")
return LoginErrorToCode().server_error.to_json_response(logger.trace_id)

View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel, Field
class AuthMeResponse(BaseModel):
success: bool = Field(..., description="是否验证成功")
userid: str = Field(..., description="用户ID")

View File

@@ -0,0 +1,37 @@
from fastapi import status
from pydantic import BaseModel, Field
from loveace.router.schemas.base import ErrorToCode, ErrorToCodeNode
class LoginRequest(BaseModel):
userid: str = Field(..., description="用户ID")
ec_password: str = Field(..., description="用户EC密码rsa encrypt加密后的密文")
password: str = Field(..., description="用户登录密码rsa encrypt加密后的密文")
class LoginResponse(BaseModel):
token: str = Field(..., description="用户登录成功后返回的Authme Token")
class LoginErrorToCode(ErrorToCode):
invalid_credentials: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_403_FORBIDDEN,
code="CREDENTIALS_INVALID",
message="凭证无效",
)
remote_invalid_credentials: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_403_FORBIDDEN,
code="REMOTE_CREDENTIALS_INVALID",
message="远程凭证无效EC密码或登录密码错误需要进行密码重置",
)
cooldown: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_429_TOO_MANY_REQUESTS,
code="COOLDOWN",
message="操作过于频繁,请稍后再试",
)
server_error: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
code="SERVER_ERROR",
message="服务器错误",
)

View File

@@ -0,0 +1,99 @@
from fastapi import status
from pydantic import BaseModel, Field
from loveace.router.schemas import (
ErrorToCode,
ErrorToCodeNode,
)
##############################################################
# * 用户注册相关模型-邀请码 *#
class InviteCodeRequest(BaseModel):
invite_code: str = Field(..., description="邀请码")
class InviteCodeResponse(BaseModel):
token: str = Field(..., description="邀请码验证成功后返回的Token")
class InviteErrorToCode(ErrorToCode):
invalid_invite_code: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_403_FORBIDDEN,
code="INVITE_CODE_INVALID",
message="邀请码错误",
)
server_error: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
code="SERVER_ERROR",
message="服务器错误",
)
##############################################################
##############################################################
# * 用户注册相关模型-注册 *#
class RegisterRequest(BaseModel):
userid: str = Field(..., description="用户ID")
ec_password: str = Field(..., description="用户EC密码rsa encrypt加密后的密文")
password: str = Field(..., description="用户登录密码rsa encrypt加密后的密文")
token: str = Field(..., description="邀请码验证成功后返回的Token")
class RegisterResponse(BaseModel):
token: str = Field(..., description="用户登录成功后返回的Authme Token")
class RegisterErrorToCode(ErrorToCode):
invalid_token: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_403_FORBIDDEN,
code="TOKEN_INVALID",
message="Token无效",
)
userid_exists: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_409_CONFLICT,
code="USERID_EXISTS",
message="用户ID已存在",
)
decrypt_error: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_400_BAD_REQUEST,
code="DECRYPT_ERROR",
message="密码解密失败",
)
ec_server_error: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_400_BAD_REQUEST,
code="EC_SERVER_ERROR",
message="EC服务错误",
)
ec_password_error: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_400_BAD_REQUEST,
code="EC_PASSWORD_ERROR",
message="EC密码错误",
)
uaap_server_error: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_400_BAD_REQUEST,
code="UAAP_SERVER_ERROR",
message="UAAP服务错误",
)
uaap_password_error: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_400_BAD_REQUEST,
code="UAAP_PASSWORD_ERROR",
message="UAAP密码错误",
)
register_in_cooldown: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_429_TOO_MANY_REQUESTS,
code="REGISTER_IN_COOLDOWN",
message="注册请求过于频繁,请稍后再试",
)
server_error: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
code="SERVER_ERROR",
message="服务器错误",
)
##############################################################

View File

@@ -0,0 +1,247 @@
import secrets
from datetime import datetime, timedelta
from uuid import uuid4
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from loveace.config.logger import LoggerMixin
from loveace.database.auth.register import InviteCode as InviteCodeDB
from loveace.database.auth.register import RegisterCoolDown
from loveace.database.auth.token import AuthMEToken
from loveace.database.auth.user import ACEUser
from loveace.database.creator import get_db_session
from loveace.router.dependencies.logger import no_user_logger_mixin
from loveace.router.endpoint.auth.model.register import (
InviteCodeRequest,
InviteCodeResponse,
InviteErrorToCode,
RegisterErrorToCode,
RegisterRequest,
RegisterResponse,
)
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEService
from loveace.service.remote.aufe.depends import get_aufe_service
from loveace.utils.rsa import RSAUtils
register_router = APIRouter(prefix="/register")
temp_tokens = []
rsa_util = RSAUtils.get_or_create_rsa_utils()
@register_router.post(
"/invite",
response_model=UniResponseModel[InviteCodeResponse],
responses=InviteErrorToCode.gen_code_table(),
summary="邀请码验证",
)
async def register(
invite_code: InviteCodeRequest,
db: AsyncSession = Depends(get_db_session),
logger: LoggerMixin = Depends(no_user_logger_mixin),
) -> UniResponseModel[InviteCodeResponse] | JSONResponse:
"""
验证邀请码并返回临时 Token
✅ 功能特性:
- 验证邀请码的有效性
- 生成临时 Token 用于后续注册步骤
- 邀请码一次性使用
💡 使用场景:
- 用户注册流程的第一步
- 邀请制系统的验证
Args:
invite_code: 邀请码请求对象
db: 数据库会话
logger: 日志记录器
Returns:
InviteCodeResponse: 包含临时 Token
"""
try:
async with db as session:
logger.info(f"邀请码: {invite_code.invite_code}")
invite = select(InviteCodeDB).where(
InviteCodeDB.code == invite_code.invite_code
)
result = await session.execute(invite)
invite_data = result.scalars().first()
if invite_data is None:
logger.info(f"邀请码不存在: {invite_code.invite_code}")
return InviteErrorToCode().invalid_invite_code.to_json_response(
logger.trace_id
)
token = secrets.token_urlsafe(128)
temp_tokens.append(token)
return UniResponseModel[InviteCodeResponse](
success=True,
data=InviteCodeResponse(token=token),
message="邀请码验证成功",
error=None,
)
except Exception as e:
logger.error("邀请码验证失败:")
logger.exception(e)
return InviteErrorToCode().server_error.to_json_response(logger.trace_id)
@register_router.post(
"/next",
response_model=UniResponseModel[RegisterResponse],
responses=RegisterErrorToCode.gen_code_table(),
summary="用户注册",
)
async def register_user(
register_info: RegisterRequest,
db: AsyncSession = Depends(get_db_session),
logger: LoggerMixin = Depends(no_user_logger_mixin),
aufe_service: AUFEService = Depends(get_aufe_service),
) -> UniResponseModel[RegisterResponse] | JSONResponse:
"""
用户注册,验证身份并创建账户
✅ 功能特性:
- 通过 AUFE 服务验证 EC 密码和登录密码
- 验证身份信息的有效性
- 生成 Authme Token 用于登录
⚠️ 限制条件:
- EC 密码或登录密码错误会触发 5 分钟冷却时间
- 用户 ID 不能重复
- 必须提供有效的邀请 Token
💡 使用场景:
- 新用户注册
- 创建学号对应的账户
Args:
register_info: 包含用户 ID、EC 密码、登录密码和邀请 Token 的注册信息
db: 数据库会话
logger: 日志记录器
aufe_service: AUFE 远程认证服务
Returns:
RegisterResponse: 包含 Authme Token
"""
try:
async with db as session:
# COOLDOWN检查
query = select(RegisterCoolDown).where(
RegisterCoolDown.userid == register_info.userid
)
result = await session.execute(query)
cooldown = result.scalars().first()
if cooldown:
if cooldown.expire_date > datetime.now():
logger.info(f"用户ID注册冷却中: {register_info.userid}")
return RegisterErrorToCode().userid_exists.to_json_response(
logger.trace_id
)
else:
await session.delete(cooldown)
await session.commit()
if register_info.token not in temp_tokens:
logger.info(f"无效的注册Token: {register_info.token}")
return RegisterErrorToCode().invalid_token.to_json_response(
logger.trace_id
)
query = select(ACEUser).where(ACEUser.userid == register_info.userid)
result = await session.execute(query)
user = result.scalars().first()
if user is not None:
logger.info(f"用户ID已存在: {register_info.userid}")
return RegisterErrorToCode().userid_exists.to_json_response(
logger.trace_id
)
# 尝试使用AUFE服务验证EC密码
try:
ec_password = rsa_util.decrypt(register_info.ec_password)
password = rsa_util.decrypt(register_info.password)
except Exception as e:
logger.info(f"用户 {register_info.userid} 提供的密码解密失败: {e}")
return RegisterErrorToCode().decrypt_error.to_json_response(
logger.trace_id
)
conn = await aufe_service.get_or_create_connection(
userid=register_info.userid,
ec_password=ec_password,
password=password,
)
ec_login_status = await conn.ec_login()
if not ec_login_status.success:
cooldown_entry = RegisterCoolDown(
userid=register_info.userid,
expire_date=datetime.now() + timedelta(minutes=5),
)
session.add(cooldown_entry)
await session.commit()
if ec_login_status.fail_invalid_credentials:
logger.info(f"EC密码错误: {register_info.userid}")
return RegisterErrorToCode().ec_password_error.to_json_response(
logger.trace_id
)
else:
logger.error(f"AUFE服务异常: {ec_login_status}")
return RegisterErrorToCode().ec_server_error.to_json_response(
logger.trace_id
)
uaap_login_status = await conn.uaap_login()
if not uaap_login_status.success:
cooldown_entry = RegisterCoolDown(
userid=register_info.userid,
expire_date=datetime.now() + timedelta(minutes=5),
)
session.add(cooldown_entry)
await session.commit()
if uaap_login_status.fail_invalid_credentials:
logger.info(f"登录密码错误: {register_info.userid}")
return RegisterErrorToCode().uaap_password_error.to_json_response(
logger.trace_id
)
else:
logger.error(f"AUFE服务异常: {uaap_login_status}")
return RegisterErrorToCode().ec_server_error.to_json_response(
logger.trace_id
)
# 创建新用户
new_user = ACEUser(
userid=register_info.userid,
ec_password=register_info.ec_password,
password=register_info.password,
)
session.add(new_user)
await session.commit()
# 注册成功后删除临时Token
temp_tokens.remove(register_info.token)
# 生成Authme Token
authme_token = secrets.token_urlsafe(128)
new_token = AuthMEToken(
user_id=new_user.userid, token=authme_token, device_id=uuid4().hex
)
session.add(new_token)
await session.commit()
return UniResponseModel[RegisterResponse](
success=True,
data=RegisterResponse(token=authme_token),
message="注册成功",
error=None,
)
except ValueError as ve:
logger.error("用户注册失败: RSA解密错误")
logger.exception(ve)
return RegisterErrorToCode().server_error.to_json_response(
logger.trace_id, "RSA解密错误请检查授权密文"
)
except Exception as e:
logger.error("用户注册失败:")
logger.exception(e)
return RegisterErrorToCode().server_error.to_json_response(logger.trace_id)

View File

@@ -0,0 +1,12 @@
from fastapi import APIRouter
from loveace.router.endpoint.isim.elec import isim_elec_router
from loveace.router.endpoint.isim.room import isim_room_router
isim_base_router = APIRouter(
prefix="/isim",
tags=["电费"],
)
isim_base_router.include_router(isim_room_router)
isim_base_router.include_router(isim_elec_router)

View File

@@ -0,0 +1,74 @@
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from loveace.database.isim.room import RoomBind
from loveace.router.endpoint.isim.model.isim import (
UniISIMInfoResponse,
)
from loveace.router.endpoint.isim.model.protect_router import ISIMRouterErrorToCode
from loveace.router.endpoint.isim.utils.isim import ISIMClient, get_isim_client
from loveace.router.endpoint.isim.utils.room import get_bound_room
from loveace.router.schemas.uniresponse import UniResponseModel
isim_elec_router = APIRouter(
prefix="/elec",
responses=ISIMRouterErrorToCode().gen_code_table(),
)
@isim_elec_router.get(
"/info",
summary="获取寝室电费信息",
response_model=UniResponseModel[UniISIMInfoResponse],
)
async def get_isim_info(
isim: ISIMClient = Depends(get_isim_client),
room: RoomBind = Depends(get_bound_room),
) -> UniResponseModel[UniISIMInfoResponse] | JSONResponse:
"""
获取用户绑定宿舍的电费信息
✅ 功能特性:
- 获取当前电费余额
- 获取用电记录历史
- 获取缴费记录
💡 使用场景:
- 个人中心查看宿舍电费
- 监测用电情况
- 查看缴费历史
Returns:
UniISIMInfoResponse: 包含房间信息、电费余额、用电记录、缴费记录
"""
try:
# 使用 ISIMClient 的集成方法获取电费信息
result = await isim.get_electricity_info(room.roomid)
if result is None:
isim.client.logger.error(f"获取寝室 {room.roomid} 电费信息失败")
return ISIMRouterErrorToCode().remote_service_error.to_json_response(
isim.client.logger.trace_id
)
room_display = await isim.get_room_display_text(room.roomid)
room_display = "" if room_display is None else room_display
return UniResponseModel[UniISIMInfoResponse](
success=True,
data=UniISIMInfoResponse(
room_code=room.roomid,
room_display=room_display,
room_text=room.roomtext,
balance=result["balance"],
usage_records=result["usage_records"],
payments=result["payments"],
),
message="获取寝室电费信息成功",
error=None,
)
except Exception as e:
isim.client.logger.error("获取寝室电费信息异常")
isim.client.logger.exception(e)
return ISIMRouterErrorToCode().server_error.to_json_response(
isim.client.logger.trace_id
)

View File

@@ -0,0 +1,42 @@
from typing import List
from pydantic import BaseModel, Field
# ==================== 电费相关模型 ====================
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 PaymentRecord(BaseModel):
"""充值记录"""
payment_time: str = Field(..., description="充值时间2025-02-21 11:30:08")
amount: float = Field(..., description="充值金额(元)")
payment_type: str = Field(..., description="充值类型,如:下发补助、一卡通充值")
class UniISIMInfoResponse(BaseModel):
"""寝室电费信息"""
room_code: str = Field(..., description="寝室代码")
room_text: str = Field(..., description="寝室显示名称")
room_display: str = Field(..., description="寝室显示名称")
balance: ElectricityBalance = Field(..., description="电费余额")
usage_records: List[ElectricityUsageRecord] = Field(..., description="用电记录")
payments: List[PaymentRecord] = Field(..., description="充值记录")

View File

@@ -0,0 +1,18 @@
from fastapi import status
from loveace.router.schemas.error import ErrorToCodeNode, ProtectRouterErrorToCode
class ISIMRouterErrorToCode(ProtectRouterErrorToCode):
"""ISIM 统一错误码"""
UNBOUNDROOM: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_400_BAD_REQUEST,
code="UNBOUND_ROOM",
message="房间未绑定",
)
CACHEDROOMSEXPIRED: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_400_BAD_REQUEST,
code="CACHED_ROOMS_EXPIRED",
message="房间缓存已过期,请稍后重新获取房间列表",
)

View File

@@ -0,0 +1,172 @@
from typing import List
from pydantic import BaseModel, Field
##############################################################
# * 寝室绑定请求模型 *#
##############################################################
class BindRoomRequest(BaseModel):
"""绑定寝室请求模型"""
room_id: str = Field(..., description="寝室ID")
##############################################################
# * 寝室绑定响应模型 *#
##############################################################
class BindRoomResponse(BaseModel):
"""绑定寝室响应模型"""
success: bool = Field(..., description="是否绑定成功")
##############################################################
# * 楼栋信息模型 *#
##############################################################
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 CacheFloorData(BaseModel):
"""缓存的楼层信息"""
code: str = Field(..., description="楼层代码")
name: str = Field(..., description="楼层名称")
rooms: List[RoomInfo] = Field(..., description="房间列表")
class CacheBuildingData(BaseModel):
"""缓存的楼栋信息"""
code: str = Field(..., description="楼栋代码")
name: str = Field(..., description="楼栋名称")
floors: List[CacheFloorData] = Field(..., description="楼层列表")
class CacheRoomsData(BaseModel):
"""缓存的寝室信息"""
datetime: str = Field(..., description="数据更新时间格式YYYY-MM-DD HH:MM:SS")
data: List[CacheBuildingData] = 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 CurrentRoomResponse(BaseModel):
"""获取当前宿舍响应模型"""
room_code: str = Field(..., description="房间代码")
display_text: str = Field(
..., description="显示文本北苑11号学生公寓/11-6层/11-627"
)
##############################################################
# * 强制刷新响应模型 *#
##############################################################
class ForceRefreshResponse(BaseModel):
"""强制刷新响应模型"""
success: bool = Field(..., description="是否刷新成功")
message: str = Field(..., description="响应消息")
remaining_cooldown: float = Field(
default=0.0, description="剩余冷却时间0表示无冷却"
)
##############################################################
# * 楼层房间查询响应模型 *#
##############################################################
class FloorRoomsResponse(BaseModel):
"""楼层房间查询响应模型"""
floor_code: str = Field(..., description="楼层代码")
floor_name: str = Field(..., description="楼层名称")
building_code: str = Field(..., description="所属楼栋代码")
rooms: List[RoomInfo] = Field(..., description="房间列表")
room_count: int = Field(..., description="房间数量")
##############################################################
# * 房间详情查询响应模型 *#
##############################################################
class RoomDetailResponse(BaseModel):
"""房间详情查询响应模型"""
room_code: str = Field(..., description="房间代码")
room_name: str = Field(..., description="房间名称")
floor_code: str = Field(..., description="所属楼层代码")
floor_name: str = Field(..., description="所属楼层名称")
building_code: str = Field(..., description="所属楼栋代码")
building_name: str = Field(..., description="所属楼栋名称")
display_text: str = Field(..., description="完整显示文本")
##############################################################
# * 楼栋列表响应模型 *#
##############################################################
class BuildingListResponse(BaseModel):
"""楼栋列表响应模型"""
buildings: List[BuildingInfo] = Field(..., description="楼栋列表")
building_count: int = Field(..., description="楼栋数量")
datetime: str = Field(..., description="数据更新时间")

View File

@@ -0,0 +1,544 @@
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from loveace.database.auth.user import ACEUser
from loveace.database.creator import get_db_session
from loveace.database.isim.room import RoomBind
from loveace.router.dependencies.auth import get_user_by_token
from loveace.router.endpoint.isim.model.protect_router import ISIMRouterErrorToCode
from loveace.router.endpoint.isim.model.room import (
BindRoomRequest,
BindRoomResponse,
BuildingInfo,
BuildingListResponse,
CacheRoomsData,
CurrentRoomResponse,
FloorRoomsResponse,
ForceRefreshResponse,
RoomDetailResponse,
)
from loveace.router.endpoint.isim.utils.isim import ISIMClient, get_isim_client
from loveace.router.endpoint.isim.utils.lock_manager import get_refresh_lock_manager
from loveace.router.schemas.uniresponse import UniResponseModel
isim_room_router = APIRouter(
prefix="/room",
responses=ISIMRouterErrorToCode.gen_code_table(),
)
@isim_room_router.get(
"/list",
summary="[完整数据] 获取所有楼栋、楼层、房间的完整树形结构",
response_model=UniResponseModel[CacheRoomsData],
)
async def get_rooms(
isim_conn: ISIMClient = Depends(get_isim_client),
) -> UniResponseModel[CacheRoomsData] | JSONResponse:
"""
获取完整的寝室列表(所有楼栋、楼层、房间的树形结构)
⚠️ 数据量大:包含所有楼栋的完整数据,适合需要完整数据的场景
💡 建议:移动端或需要部分数据的场景,请使用其他精细化查询接口
"""
try:
rooms = await isim_conn.get_cached_rooms()
if not rooms:
return ISIMRouterErrorToCode().null_response.to_json_response(
isim_conn.client.logger.trace_id
)
return UniResponseModel[CacheRoomsData](
success=True,
data=rooms,
message="获取寝室列表成功",
error=None,
)
except Exception as e:
isim_conn.client.logger.error(f"获取寝室列表异常: {str(e)}")
return ISIMRouterErrorToCode().server_error.to_json_response(
isim_conn.client.logger.trace_id
)
@isim_room_router.get(
"/list/buildings",
summary="[轻量级] 获取所有楼栋列表(仅楼栋信息,不含楼层和房间)",
response_model=UniResponseModel[BuildingListResponse],
)
async def get_all_buildings(
isim_conn: ISIMClient = Depends(get_isim_client),
) -> UniResponseModel[BuildingListResponse] | JSONResponse:
"""
获取所有楼栋列表(仅楼栋的代码和名称)
✅ 数据量小:只返回楼栋列表,不包含楼层和房间
💡 使用场景:
- 楼栋选择器
- 第一级导航菜单
- 需要快速获取楼栋列表的场景
"""
logger = isim_conn.client.logger
try:
# 从Hash缓存获取完整数据
full_data = await isim_conn.get_cached_rooms()
if not full_data or not full_data.data:
logger.warning("楼栋数据不存在")
return ISIMRouterErrorToCode().null_response.to_json_response(
logger.trace_id
)
# 提取楼栋信息
buildings = [
{"code": building.code, "name": building.name}
for building in full_data.data
]
result = BuildingListResponse(
buildings=[BuildingInfo(**b) for b in buildings],
building_count=len(buildings),
datetime=full_data.datetime,
)
logger.info(f"成功获取楼栋列表,共 {len(buildings)} 个楼栋")
return UniResponseModel[BuildingListResponse](
success=True,
data=result,
message=f"获取楼栋列表成功,共 {len(buildings)} 个楼栋",
error=None,
)
except Exception as e:
logger.error(f"获取楼栋列表异常: {str(e)}")
return ISIMRouterErrorToCode().server_error.to_json_response(logger.trace_id)
@isim_room_router.get(
"/list/building/{building_code}",
summary="[按楼栋查询] 获取指定楼栋的所有楼层和房间",
response_model=UniResponseModel[CacheRoomsData],
)
async def get_building_rooms(
building_code: str, isim_conn: ISIMClient = Depends(get_isim_client)
) -> UniResponseModel[CacheRoomsData] | JSONResponse:
"""
获取指定楼栋及其所有楼层和房间的完整数据
✅ 数据量适中只返回单个楼栋的数据比完整列表小90%+
💡 使用场景:
- 用户选择楼栋后,展示该楼栋的所有楼层和房间
- 楼栋详情页
- 减少移动端流量消耗
Args:
building_code: 楼栋代码01, 02, 11等
"""
logger = isim_conn.client.logger
try:
# 使用Hash精细化查询只获取指定楼栋
building_data = await isim_conn.get_building_with_floors(building_code)
if not building_data:
logger.warning(f"楼栋 {building_code} 不存在或无数据")
return ISIMRouterErrorToCode().null_response.to_json_response(
logger.trace_id
)
# 构造响应数据
import datetime
result = CacheRoomsData(
datetime=datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
data=[building_data],
)
logger.info(
f"成功获取楼栋 {building_code} 信息,"
f"楼层数: {len(building_data.floors)}, "
f"房间数: {sum(len(f.rooms) for f in building_data.floors)}"
)
return UniResponseModel[CacheRoomsData](
success=True,
data=result,
message=f"获取楼栋 {building_code} 信息成功",
error=None,
)
except Exception as e:
logger.error(f"获取楼栋 {building_code} 信息异常: {str(e)}")
return ISIMRouterErrorToCode().server_error.to_json_response(logger.trace_id)
@isim_room_router.get(
"/list/floor/{floor_code}",
summary="[按楼层查询] 获取指定楼层的所有房间列表",
response_model=UniResponseModel[FloorRoomsResponse],
)
async def get_floor_rooms(
floor_code: str, isim_conn: ISIMClient = Depends(get_isim_client)
) -> UniResponseModel[FloorRoomsResponse] | JSONResponse:
"""
获取指定楼层的所有房间信息
✅ 数据量最小:只返回单个楼层的房间列表,极小数据量
💡 使用场景:
- 用户选择楼层后,展示该楼层的所有房间
- 房间选择器的第三级
- 移动端分页加载
- 需要最快响应速度的场景
Args:
floor_code: 楼层代码0101, 0102, 1101等
"""
logger = isim_conn.client.logger
try:
# 获取楼层信息
floor_info = await isim_conn.get_floor_info(floor_code)
if not floor_info:
logger.warning(f"楼层 {floor_code} 不存在")
return ISIMRouterErrorToCode().null_response.to_json_response(
logger.trace_id
)
# 获取房间列表从Hash直接查询非常快速
rooms = await isim_conn.get_rooms_by_floor(floor_code)
# 从楼层代码提取楼栋代码前2位
building_code = floor_code[:2] if len(floor_code) >= 2 else ""
result = FloorRoomsResponse(
floor_code=floor_info.code,
floor_name=floor_info.name,
building_code=building_code,
rooms=rooms,
room_count=len(rooms),
)
logger.info(
f"成功获取楼层 {floor_code} ({floor_info.name}) 的房间信息,共 {len(rooms)} 个房间"
)
return UniResponseModel[FloorRoomsResponse](
success=True,
data=result,
message=f"获取楼层 {floor_code} 的房间信息成功,共 {len(rooms)} 个房间",
error=None,
)
except Exception as e:
logger.error(f"获取楼层 {floor_code} 房间信息异常: {str(e)}")
return ISIMRouterErrorToCode().server_error.to_json_response(logger.trace_id)
@isim_room_router.get(
"/info/{room_code}",
summary="[房间详情] 获取单个房间的完整层级信息",
response_model=UniResponseModel[RoomDetailResponse],
)
async def get_room_info(
room_code: str, isim_conn: ISIMClient = Depends(get_isim_client)
) -> UniResponseModel[RoomDetailResponse] | JSONResponse:
"""
获取指定房间的完整信息(包括楼栋、楼层、房间的完整层级结构)
✅ 功能强大:一次性返回房间的完整上下文信息
💡 使用场景:
- 房间详情页展示
- 显示完整的 "楼栋/楼层/房间" 路径
- 房间搜索结果展示
- 需要房间完整信息的场景
Args:
room_code: 房间代码010101, 110627等
"""
logger = isim_conn.client.logger
try:
# 提取层级代码
if len(room_code) < 4:
logger.warning(f"房间代码 {room_code} 格式错误")
return ISIMRouterErrorToCode().null_response.to_json_response(
logger.trace_id
)
building_code = room_code[:2]
floor_code = room_code[:4]
# 并发获取所有需要的信息
import asyncio
building_info, floor_info, room_info = await asyncio.gather(
isim_conn.get_building_info(building_code),
isim_conn.get_floor_info(floor_code),
isim_conn.query_room_info_fast(room_code),
)
if not room_info:
logger.warning(f"房间 {room_code} 不存在")
return ISIMRouterErrorToCode().null_response.to_json_response(
logger.trace_id
)
# 构造显示文本
building_name = building_info.name if building_info else "未知楼栋"
floor_name = floor_info.name if floor_info else "未知楼层"
display_text = f"{building_name}/{floor_name}/{room_info.name}"
result = RoomDetailResponse(
room_code=room_info.code,
room_name=room_info.name,
floor_code=floor_code,
floor_name=floor_name,
building_code=building_code,
building_name=building_name,
display_text=display_text,
)
logger.info(f"成功获取房间 {room_code} 的详细信息: {display_text}")
return UniResponseModel[RoomDetailResponse](
success=True,
data=result,
message=f"获取房间 {room_code} 的详细信息成功",
error=None,
)
except Exception as e:
logger.error(f"获取房间 {room_code} 详细信息异常: {str(e)}")
return ISIMRouterErrorToCode().server_error.to_json_response(logger.trace_id)
@isim_room_router.post(
"/bind",
summary="[用户操作] 绑定寝室到当前用户",
response_model=UniResponseModel[BindRoomResponse],
)
async def bind_room(
bind_request: BindRoomRequest,
isim_conn: ISIMClient = Depends(get_isim_client),
db: AsyncSession = Depends(get_db_session),
) -> UniResponseModel[BindRoomResponse] | JSONResponse:
"""
绑定寝室到当前用户(存在即更新)
💡 使用场景:
- 用户首次绑定寝室
- 用户更换寝室
- 修改绑定信息
"""
logger = isim_conn.client.logger
try:
exist = await db.execute(
select(RoomBind).where(RoomBind.user_id == isim_conn.client.userid)
)
exist = exist.scalars().first()
if exist:
if exist.roomid == bind_request.room_id:
return UniResponseModel[BindRoomResponse](
success=True,
data=BindRoomResponse(success=True),
message="宿舍绑定成功",
error=None,
)
else:
# 使用快速查询方法从Hash直接获取无需遍历完整树
room_info = await isim_conn.query_room_info_fast(bind_request.room_id)
roomtext = room_info.name if room_info else None
# 如果Hash中没有回退到完整查询
if not roomtext:
roomtext = await isim_conn.query_room_name(bind_request.room_id)
await db.execute(
update(RoomBind)
.where(RoomBind.user_id == isim_conn.client.userid)
.values(roomid=bind_request.room_id, roomtext=roomtext)
)
await db.commit()
logger.info(f"更新寝室绑定成功: {roomtext}({bind_request.room_id})")
return UniResponseModel[BindRoomResponse](
success=True,
data=BindRoomResponse(success=True),
message="宿舍绑定成功",
error=None,
)
else:
# 使用快速查询方法从Hash直接获取无需遍历完整树
room_info = await isim_conn.query_room_info_fast(bind_request.room_id)
roomtext = room_info.name if room_info else None
# 如果Hash中没有回退到完整查询
if not roomtext:
roomtext = await isim_conn.query_room_name(bind_request.room_id)
new_bind = RoomBind(
user_id=isim_conn.client.userid,
roomid=bind_request.room_id,
roomtext=roomtext,
)
db.add(new_bind)
await db.commit()
logger.info(f"新增寝室绑定成功: {roomtext}({bind_request.room_id})")
return UniResponseModel[BindRoomResponse](
success=True,
data=BindRoomResponse(success=True),
message="宿舍绑定成功",
error=None,
)
except Exception as e:
logger.error(f"绑定寝室异常: {str(e)}")
return ISIMRouterErrorToCode().server_error.to_json_response(
isim_conn.client.logger.trace_id
)
@isim_room_router.get(
"/current",
summary="[用户查询] 获取当前用户绑定的宿舍信息",
response_model=UniResponseModel[CurrentRoomResponse],
)
async def get_current_room(
user: ACEUser = Depends(get_user_by_token),
isim_conn: ISIMClient = Depends(get_isim_client),
db: AsyncSession = Depends(get_db_session),
) -> UniResponseModel[CurrentRoomResponse] | JSONResponse:
"""
获取当前用户绑定的宿舍信息,返回 room_code 和 display_text
💡 使用场景:
- 个人中心显示已绑定宿舍
- 查询当前用户的寝室信息
- 验证用户是否已绑定寝室
"""
logger = isim_conn.client.logger
try:
# 查询用户绑定的房间
result = await db.execute(
select(RoomBind).where(RoomBind.user_id == user.userid)
)
room_bind = result.scalars().first()
if not room_bind:
logger.warning(f"用户 {user.userid} 未绑定宿舍")
return UniResponseModel[CurrentRoomResponse](
success=True,
data=CurrentRoomResponse(
room_code="",
display_text="",
),
message="获取宿舍信息成功,用户未绑定宿舍",
error=None,
)
# 优先从Hash缓存快速获取房间显示文本
display_text = await isim_conn.get_room_display_text(room_bind.roomid)
if not display_text:
# 如果缓存中没有,使用数据库中存储的文本
display_text = room_bind.roomtext
logger.debug(
f"Hash缓存中未找到房间 {room_bind.roomid},使用数据库存储的文本"
)
logger.info(f"成功获取用户 {user.userid} 的宿舍信息: {display_text}")
return UniResponseModel[CurrentRoomResponse](
success=True,
data=CurrentRoomResponse(
room_code=room_bind.roomid,
display_text=display_text,
),
message="获取宿舍信息成功",
error=None,
)
except Exception as e:
logger.error(f"获取当前宿舍异常: {str(e)}")
return ISIMRouterErrorToCode().server_error.to_json_response(logger.trace_id)
@isim_room_router.post(
"/refresh",
summary="[管理操作] 强制刷新房间列表缓存",
response_model=UniResponseModel[ForceRefreshResponse],
)
async def force_refresh_rooms(
isim_conn: ISIMClient = Depends(get_isim_client),
) -> UniResponseModel[ForceRefreshResponse] | JSONResponse:
"""
强制刷新房间列表缓存从ISIM系统重新获取数据
⚠️ 限制:
- 使用全局锁确保同一时间只有一个请求在执行刷新操作
- 刷新完成后有30分钟的冷却时间
💡 使用场景:
- 发现数据不准确时手动刷新
- 管理员更新缓存数据
- 调试和测试
"""
logger = isim_conn.client.logger
lock_manager = get_refresh_lock_manager()
try:
# 尝试获取锁
acquired, remaining_cooldown = await lock_manager.try_acquire()
if not acquired:
if remaining_cooldown is not None:
# 在冷却期内
minutes = int(remaining_cooldown // 60)
seconds = int(remaining_cooldown % 60)
message = f"刷新操作冷却中,请在 {minutes}{seconds} 秒后重试"
logger.warning(f"刷新请求被拒绝: {message}")
return UniResponseModel[ForceRefreshResponse](
success=False,
data=ForceRefreshResponse(
success=False,
message=message,
remaining_cooldown=remaining_cooldown,
),
message=message,
error=None,
)
else:
# 有其他人正在刷新
message = "其他用户正在刷新房间列表,请稍后再试"
logger.warning(message)
return UniResponseModel[ForceRefreshResponse](
success=False,
data=ForceRefreshResponse(
success=False,
message=message,
remaining_cooldown=0.0,
),
message=message,
error=None,
)
# 成功获取锁,执行刷新操作
try:
logger.info("开始强制刷新房间列表缓存")
await isim_conn.force_refresh_room_cache()
logger.info("房间列表缓存刷新完成")
return UniResponseModel[ForceRefreshResponse](
success=True,
data=ForceRefreshResponse(
success=True,
message="房间列表刷新成功",
remaining_cooldown=0.0,
),
message="房间列表刷新成功",
error=None,
)
finally:
# 释放锁并设置冷却时间
lock_manager.release()
logger.info("刷新锁已释放,冷却时间已设置")
except Exception as e:
logger.error(f"强制刷新房间列表异常: {str(e)}")
# 确保异常时也释放锁
if lock_manager.is_refreshing():
lock_manager.release()
return ISIMRouterErrorToCode().server_error.to_json_response(logger.trace_id)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
"""
全局锁管理器模块
用于管理需要冷却时间(CD)的操作锁
"""
import asyncio
import time
from typing import Optional
class RefreshLockManager:
"""刷新操作锁管理器确保一次只能执行一个刷新操作且有30分钟的冷却时间"""
def __init__(self, cooldown_seconds: int = 1800): # 默认30分钟 = 1800秒
"""
初始化锁管理器
Args:
cooldown_seconds: 冷却时间默认为1800秒30分钟
"""
self._lock = asyncio.Lock()
self._last_refresh_time: Optional[float] = None
self._cooldown_seconds = cooldown_seconds
self._is_refreshing = False
async def try_acquire(self) -> tuple[bool, Optional[float]]:
"""
尝试获取锁并检查冷却时间
Returns:
tuple[bool, Optional[float]]:
- bool: 是否成功获取锁(未在冷却期且未被占用)
- Optional[float]: 如果在冷却期返回剩余冷却时间否则为None
"""
# 检查是否有其他人正在刷新
if self._is_refreshing:
return False, None
# 检查冷却时间
if self._last_refresh_time is not None:
elapsed = time.time() - self._last_refresh_time
if elapsed < self._cooldown_seconds:
remaining_cooldown = self._cooldown_seconds - elapsed
return False, remaining_cooldown
# 尝试获取锁(非阻塞)
acquired = not self._lock.locked()
if acquired:
await self._lock.acquire()
self._is_refreshing = True
return acquired, None
def release(self):
"""
释放锁并更新最后刷新时间
"""
self._last_refresh_time = time.time()
self._is_refreshing = False
if self._lock.locked():
self._lock.release()
def get_remaining_cooldown(self) -> Optional[float]:
"""
获取剩余冷却时间
Returns:
Optional[float]: 剩余冷却时间如果不在冷却期则返回None
"""
if self._last_refresh_time is None:
return None
elapsed = time.time() - self._last_refresh_time
if elapsed < self._cooldown_seconds:
return self._cooldown_seconds - elapsed
return None
def is_in_cooldown(self) -> bool:
"""
检查是否在冷却期内
Returns:
bool: 是否在冷却期内
"""
return self.get_remaining_cooldown() is not None
def is_refreshing(self) -> bool:
"""
检查是否正在刷新
Returns:
bool: 是否正在刷新
"""
return self._is_refreshing
# 全局单例实例
_refresh_lock_manager = RefreshLockManager(cooldown_seconds=1800) # 30分钟
def get_refresh_lock_manager() -> RefreshLockManager:
"""
获取全局刷新锁管理器实例
Returns:
RefreshLockManager: 全局锁管理器实例
"""
return _refresh_lock_manager

View File

@@ -0,0 +1,24 @@
from fastapi import Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from loveace.database.creator import get_db_session
from loveace.database.isim.room import RoomBind
from loveace.router.endpoint.isim.model.protect_router import ISIMRouterErrorToCode
from loveace.router.endpoint.isim.utils.isim import ISIMClient, get_isim_client
async def get_bound_room(
isim_conn: ISIMClient = Depends(get_isim_client),
db: AsyncSession = Depends(get_db_session),
) -> RoomBind:
"""获取已绑定的寝室"""
result = await db.execute(
select(RoomBind).where(RoomBind.user_id == isim_conn.client.userid)
)
bound_room = result.scalars().first()
if not bound_room:
raise ISIMRouterErrorToCode.UNBOUNDROOM.to_http_exception(
isim_conn.client.logger.trace_id
)
return bound_room

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter
from loveace.router.endpoint.jwc.academic import jwc_academic_router
from loveace.router.endpoint.jwc.competition import jwc_competition_router
from loveace.router.endpoint.jwc.exam import jwc_exam_router
from loveace.router.endpoint.jwc.plan import jwc_plan_router
from loveace.router.endpoint.jwc.schedule import jwc_schedules_router
from loveace.router.endpoint.jwc.score import jwc_score_router
from loveace.router.endpoint.jwc.term import jwc_term_router
jwc_base_router = APIRouter(prefix="/jwc", tags=["教务处"])
jwc_base_router.include_router(jwc_exam_router)
jwc_base_router.include_router(jwc_academic_router)
jwc_base_router.include_router(jwc_term_router)
jwc_base_router.include_router(jwc_score_router)
jwc_base_router.include_router(jwc_plan_router)
jwc_base_router.include_router(jwc_schedules_router)
jwc_base_router.include_router(jwc_competition_router)

View File

@@ -0,0 +1,245 @@
import re
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from loveace.router.endpoint.jwc.model.academic import (
AcademicInfo,
AcademicInfoTransformer,
CourseSelectionStatus,
CourseSelectionStatusTransformer,
TrainingPlanInfo,
TrainingPlanInfoTransformer,
)
from loveace.router.endpoint.jwc.model.base import JWCConfig
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
jwc_academic_router = APIRouter(
prefix="/academic",
responses=ProtectRouterErrorToCode.gen_code_table(),
)
ENDPOINTS = {
"academic_info": "/main/academicInfo?sf_request_type=ajax",
"training_plan": "/main/showPyfaInfo?sf_request_type=ajax",
"course_selection_status": "/main/checkSelectCourseStatus?sf_request_type=ajax",
}
@jwc_academic_router.get(
"/info", response_model=UniResponseModel[AcademicInfo], summary="获取学业信息"
)
async def get_academic_info(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[AcademicInfo] | JSONResponse:
"""
获取用户的学业信息GPA、学分等
✅ 功能特性:
- 获取当前学期学业情况
- 获取平均学分绩点GPA
- 实时从教务系统查询
💡 使用场景:
- 个人中心查看学业成绩概览
- 了解学业进展情况
- 毕业时验证学业要求
Returns:
AcademicInfo: 包含 GPA、学分、学业状态等信息
"""
try:
conn.logger.info(f"获取用户 {conn.userid} 的学业信息")
academic_info = await conn.client.post(
JWCConfig().to_full_url(ENDPOINTS["academic_info"]),
data={"flag": ""},
follow_redirects=True,
timeout=conn.timeout,
)
if not academic_info.status_code == 200:
conn.logger.error(
f"获取用户 {conn.userid} 的学业信息失败,状态码: {academic_info.status_code}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
try:
data = academic_info.json()
# 数组格式特殊处理
data_to_validate = data[0]
result = AcademicInfoTransformer.model_validate(
data_to_validate
).to_academic_info()
return UniResponseModel[AcademicInfo](
success=True,
data=result,
message="获取学业信息成功",
error=None,
)
except ValidationError as ve:
conn.logger.error("数据验证失败")
conn.logger.debug(f"数据验证失败详情: {ve}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)
@jwc_academic_router.get(
"/training_plan",
response_model=UniResponseModel[TrainingPlanInfo],
summary="获取培养方案信息",
)
async def get_training_plan_info(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[TrainingPlanInfo] | JSONResponse:
"""
获取用户的培养方案信息
✅ 功能特性:
- 获取所属专业的培养方案
- 获取年级和专业名称
- 提取关键信息(年级、专业)
💡 使用场景:
- 了解培养方案要求
- 查看所属年级和专业
- 课程规划参考
Returns:
TrainingPlanInfo: 包含方案名称、专业名称、年级信息
"""
try:
conn.logger.info(f"获取用户 {conn.userid} 的培养方案信息")
training_plan_info = await conn.client.get(
JWCConfig().to_full_url(ENDPOINTS["training_plan"]),
follow_redirects=True,
timeout=conn.timeout,
)
if not training_plan_info.status_code == 200:
conn.logger.error(
f"获取用户 {conn.userid} 的培养方案信息失败,状态码: {training_plan_info.status_code}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
try:
data = training_plan_info.json()
transformer = TrainingPlanInfoTransformer.model_validate(data)
if transformer.count > 0 and len(transformer.data) > 0:
first_plan = transformer.data[0]
if len(first_plan) >= 2:
plan_name = first_plan[0]
# 提取年级信息 - 假设格式为"20XX级..."
grade_match = re.search(r"(\d{4})级", plan_name)
grade = grade_match.group(1) if grade_match else ""
# 提取专业名称 - 假设格式为"20XX级XXX本科培养方案"
major_match = re.search(r"\d{4}级(.+?)本科", plan_name)
major_name = major_match.group(1) if major_match else ""
result = TrainingPlanInfo(
plan_name=plan_name, major_name=major_name, grade=grade
)
return UniResponseModel[TrainingPlanInfo](
success=True,
data=result,
message="获取培养方案信息成功",
error=None,
)
else:
conn.logger.error("培养方案数据格式不正确,字段数量不足")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
else:
conn.logger.error("培养方案数据为空")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except ValidationError as ve:
conn.logger.error("数据验证失败")
conn.logger.debug(f"数据验证失败详情: {ve}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)
@jwc_academic_router.get(
"/course_selection_status",
response_model=UniResponseModel[CourseSelectionStatus],
summary="获取选课状态信息",
)
async def get_course_selection_status(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[CourseSelectionStatus] | JSONResponse:
"""
获取用户的选课状态
✅ 功能特性:
- 获取当前选课时间窗口
- 获取选课开放状态
- 显示选课时间提醒
💡 使用场景:
- 查看当前是否在选课时间内
- 获取选课开始和结束时间
- 选课前的状态检查
Returns:
CourseSelectionStatus: 包含选课状态、开始时间、结束时间等
"""
try:
conn.logger.info(f"获取用户 {conn.userid} 的选课状态信息")
course_selection_status = await conn.client.get(
JWCConfig().to_full_url(ENDPOINTS["course_selection_status"]),
follow_redirects=True,
timeout=conn.timeout,
)
if not course_selection_status.status_code == 200:
conn.logger.error(
f"获取用户 {conn.userid} 的选课状态信息失败,状态码: {course_selection_status.status_code}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
try:
data = course_selection_status.json()
result = CourseSelectionStatus(
can_select=(
True
if CourseSelectionStatusTransformer.model_validate(data).status_code
== "1"
else False
)
)
return UniResponseModel[CourseSelectionStatus](
success=True,
data=result,
message="获取选课状态成功",
error=None,
)
except ValidationError as ve:
conn.logger.error("数据验证失败")
conn.logger.debug(f"数据验证失败详情: {ve}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)

View File

@@ -0,0 +1,121 @@
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from loveace.router.endpoint.jwc.model.competition import (
CompetitionFullResponse,
)
from loveace.router.endpoint.jwc.utils.aspnet_form_parser import ASPNETFormParser
from loveace.router.endpoint.jwc.utils.competition import CompetitionInfoParser
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
jwc_competition_router = APIRouter(
prefix="/competition",
responses=ProtectRouterErrorToCode().gen_code_table(),
)
ENDPOINT = {
"awards_page": "http://211-86-241-245.vpn2.aufe.edu.cn:8118/xsXmMain.aspx",
}
@jwc_competition_router.get(
"/info",
summary="获取完整学科竞赛信息",
response_model=UniResponseModel[CompetitionFullResponse],
)
async def get_full_competition_info(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[CompetitionFullResponse] | JSONResponse:
"""
获取用户的完整学科竞赛信息(一次请求获取所有数据)
✅ 功能特性:
- 一次请求获取获奖项目列表和学分汇总
- 减少网络IO调用提高性能
- 返回完整的竞赛相关数据
📊 返回数据:
- 获奖项目列表(包含项目信息、学分、奖励等)
- 学分汇总(各类学分统计)
- 学生基本信息
💡 使用场景:
- 需要完整竞赛信息的仪表板
- 移动端应用(减少请求次数)
- 性能敏感的场景
Returns:
CompetitionFullResponse: 包含完整竞赛信息的响应对象
"""
try:
conn.logger.info(f"获取用户 {conn.userid} 的完整学科竞赛信息")
# 第一次访问页面获取 HTML 内容和 Cookie
conn.logger.debug("第一次访问创新创业管理平台页面获取表单数据")
index_response = await conn.client.get(
ENDPOINT["awards_page"],
follow_redirects=True,
timeout=conn.timeout,
)
if index_response.status_code != 200:
conn.logger.error(f"第一次访问创新创业管理平台失败,状态码: {index_response.status_code}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
# 从第一次响应中提取动态表单数据
conn.logger.debug("从页面中提取动态表单数据")
try:
form_data = ASPNETFormParser.get_awards_list_form_data(index_response.text)
conn.logger.debug(f"成功提取表单数据__VIEWSTATE 长度: {len(form_data.get('__VIEWSTATE', ''))}")
except Exception as e:
conn.logger.error(f"提取表单数据失败: {e}")
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)
# 第二次请求:使用动态表单数据请求已申报奖项页面
conn.logger.debug("使用动态表单数据请求已申报奖项页面")
result_response = await conn.client.post(
ENDPOINT["awards_page"],
follow_redirects=True,
data=form_data,
timeout=conn.timeout,
)
if result_response.status_code != 200:
conn.logger.error(f"请求已申报奖项页面失败,状态码: {result_response.status_code}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
# 一次性解析所有数据
parser = CompetitionInfoParser(result_response.text)
full_response = parser.parse_full_competition_info()
conn.logger.info(
f"成功获取用户 {conn.userid} 的完整竞赛信息,共 {full_response.total_awards_count} 项获奖"
)
return UniResponseModel[CompetitionFullResponse](
success=True,
data=full_response,
message="获取竞赛信息成功",
error=None,
)
except ValidationError as e:
conn.logger.error(f"用户 {conn.userid} 的竞赛信息数据验证失败: {e}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.error(f"用户 {conn.userid} 的完整竞赛信息获取失败: {e}")
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)

View File

@@ -0,0 +1,97 @@
from datetime import datetime
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from loveace.router.endpoint.jwc.academic import get_academic_info
from loveace.router.endpoint.jwc.model.academic import AcademicInfo
from loveace.router.endpoint.jwc.model.exam import ExamInfoResponse
from loveace.router.endpoint.jwc.utils.exam import fetch_unified_exam_info
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
jwc_exam_router = APIRouter(
prefix="/exam",
responses=ProtectRouterErrorToCode().gen_code_table(),
)
@jwc_exam_router.get(
"/info", response_model=UniResponseModel[ExamInfoResponse], summary="获取考试信息"
)
async def get_exam_info(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[ExamInfoResponse] | JSONResponse:
"""
获取用户的考试信息
✅ 功能特性:
- 获取当前学期的考试安排
- 自动确定考试时间范围
- 显示考试时间、地点、课程等信息
💡 使用场景:
- 查看即将进行的考试
- 了解考试安排和地点
- 提前规划复习计划
Returns:
ExamInfoResponse: 包含考试列表和总数
"""
try:
academic_info = await get_academic_info(conn)
if isinstance(academic_info, UniResponseModel):
if academic_info.data and isinstance(academic_info.data, AcademicInfo):
term_code = academic_info.data.current_term
else:
result = ExamInfoResponse(exams=[], total_count=0)
return UniResponseModel[ExamInfoResponse](
success=False,
data=result,
message="无法获取学期信息",
error=None,
)
elif isinstance(academic_info, AcademicInfo):
term_code = academic_info.current_term
else:
result = ExamInfoResponse(exams=[], total_count=0)
return UniResponseModel[ExamInfoResponse](
success=False,
data=result,
message="无法获取学期信息",
error=None,
)
conn.logger.info(f"获取用户 {conn.userid} 的考试信息")
start_date = datetime.now()
# termcode 结尾为 1 为秋季学期考试应在3月之前2为春季学期考试应在9月之前
end_date = datetime(
year=start_date.year + (1 if term_code.endswith("1") else 0),
month=3 if term_code.endswith("1") else 9,
day=30,
)
exam_info = await fetch_unified_exam_info(
conn,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
term_code=term_code,
)
return UniResponseModel[ExamInfoResponse](
success=True,
data=exam_info,
message="获取考试信息成功",
error=None,
)
except ValidationError as e:
conn.logger.error(f"用户 {conn.userid} 的考试信息数据验证失败: {e}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.error(f"用户 {conn.userid} 的考试信息获取失败: {e}")
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)

View File

@@ -0,0 +1,67 @@
from typing import List
from pydantic import BaseModel, Field
from loveace.router.endpoint.jwc.utils.zxjxjhh_to_term_format import (
convert_zxjxjhh_to_term_format,
)
class AcademicInfoTransformer(BaseModel):
"""学术信息数据项"""
completed_courses: int = Field(0, alias="courseNum")
failed_courses: int = Field(0, alias="coursePas")
gpa: float = Field(0, alias="gpa")
current_term: str = Field("", alias="zxjxjhh")
pending_courses: int = Field(0, alias="courseNum_bxqyxd")
def to_academic_info(self) -> "AcademicInfo":
"""转换为 AcademicInfo"""
return AcademicInfo(
completed_courses=self.completed_courses,
failed_courses=self.failed_courses,
pending_courses=self.pending_courses,
gpa=self.gpa,
current_term=self.current_term,
current_term_name=convert_zxjxjhh_to_term_format(self.current_term),
)
class AcademicInfo(BaseModel):
"""学术信息数据模型"""
completed_courses: int = Field(0, description="已修课程数")
failed_courses: int = Field(0, description="不及格课程数")
pending_courses: int = Field(0, description="本学期待修课程数")
gpa: float = Field(0, description="绩点")
current_term: str = Field("", description="当前学期")
current_term_name: str = Field("", description="当前学期名称")
class TrainingPlanInfoTransformer(BaseModel):
"""培养方案响应模型"""
count: int = 0
data: List[List[str]] = []
class TrainingPlanInfo(BaseModel):
"""培养方案信息模型"""
plan_name: str = Field("", description="培养方案名称")
major_name: str = Field("", description="专业名称")
grade: str = Field("", description="年级")
class CourseSelectionStatusTransformer(BaseModel):
"""选课状态响应模型新格式"""
term_name: str = Field("", alias="zxjxjhm")
status_code: str = Field("", alias="retString")
class CourseSelectionStatus(BaseModel):
"""选课状态信息"""
can_select: bool = Field(False, description="是否可以选课")

View File

@@ -0,0 +1,10 @@
class JWCConfig:
"""教务系统配置常量"""
DEFAULT_BASE_URL = "http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/"
def to_full_url(self, path: str) -> str:
"""将路径转换为完整URL"""
if path.startswith("http://") or path.startswith("https://"):
return path
return self.DEFAULT_BASE_URL.rstrip("/") + "/" + path.lstrip("/")

View File

@@ -0,0 +1,84 @@
from typing import List, Optional
from pydantic import BaseModel, Field
class AwardProject(BaseModel):
"""
获奖项目信息模型
表示用户通过创新创业管理平台申报的单个获奖项目
"""
project_id: str = Field("", description="申报ID唯一标识符")
project_name: str = Field("", description="项目名称/赛事名称")
level: str = Field("", description="级别(校级/省部级/国家级等)")
grade: str = Field("", description="等级/奖项等级(一等奖/二等奖等)")
award_date: str = Field("", description="获奖日期,格式为 YYYY/M/D")
applicant_id: str = Field("", description="主持人姓名")
applicant_name: str = Field("", description="参与人姓名(作为用户)")
order: int = Field(0, description="顺序号(多人项目的排序)")
credits: float = Field(0.0, description="获奖学分")
bonus: float = Field(0.0, description="奖励金额")
status: str = Field("", description="申报状态(提交/审核中/已审核等)")
verification_status: str = Field(
"", description="学校审核状态(通过/未通过/待审核等)"
)
class CreditsSummary(BaseModel):
"""
学分汇总信息模型
存储用户在创新创业管理平台的各类学分统计
"""
discipline_competition_credits: Optional[float] = Field(
None, description="学科竞赛学分"
)
scientific_research_credits: Optional[float] = Field(
None, description="科研项目学分"
)
transferable_competition_credits: Optional[float] = Field(
None, description="可转竞赛类学分"
)
innovation_practice_credits: Optional[float] = Field(
None, description="创新创业实践学分"
)
ability_certification_credits: Optional[float] = Field(
None, description="能力资格认证学分"
)
other_project_credits: Optional[float] = Field(None, description="其他项目学分")
class CompetitionAwardsResponse(BaseModel):
"""
获奖项目列表响应模型
"""
student_id: str = Field("", description="学生ID/工号")
total_count: int = Field(0, description="获奖项目总数")
awards: List[AwardProject] = Field(default_factory=list, description="获奖项目列表")
class CompetitionCreditsSummaryResponse(BaseModel):
"""
学分汇总响应模型
"""
student_id: str = Field("", description="学生ID/工号")
credits_summary: Optional[CreditsSummary] = Field(None, description="学分汇总详情")
class CompetitionFullResponse(BaseModel):
"""
学科竞赛完整信息响应模型
整合了获奖项目列表和学分汇总信息减少网络IO调用
在单次请求中返回所有竞赛相关数据
"""
student_id: str = Field("", description="学生ID/工号")
total_awards_count: int = Field(0, description="获奖项目总数")
awards: List[AwardProject] = Field(default_factory=list, description="获奖项目列表")
credits_summary: Optional[CreditsSummary] = Field(None, description="学分汇总详情")

View File

@@ -0,0 +1,65 @@
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
class ExamScheduleItem(BaseModel):
"""考试安排项目 - 校统考格式"""
title: str = "" # 考试标题,包含课程名、时间、地点等信息
start: str = "" # 考试日期 (YYYY-MM-DD)
color: str = "" # 显示颜色
class OtherExamRecord(BaseModel):
"""其他考试记录"""
term_code: str = Field("", alias="ZXJXJHH") # 学期代码
term_name: str = Field("", alias="ZXJXJHM") # 学期名称
exam_name: str = Field("", alias="KSMC") # 考试名称
course_code: str = Field("", alias="KCH") # 课程代码
course_name: str = Field("", alias="KCM") # 课程名称
class_number: str = Field("", alias="KXH") # 课序号
student_id: str = Field("", alias="XH") # 学号
student_name: str = Field("", alias="XM") # 姓名
exam_location: str = Field("", alias="KSDD") # 考试地点
exam_date: str = Field("", alias="KSRQ") # 考试日期
exam_time: str = Field("", alias="KSSJ") # 考试时间
note: str = Field("", alias="BZ") # 备注
row_number: str = Field("", alias="RN") # 行号
class OtherExamResponse(BaseModel):
"""其他考试查询响应"""
page_size: int = Field(0, alias="pageSize")
page_num: int = Field(0, alias="pageNum")
page_context: Dict[str, int] = Field(default_factory=dict, alias="pageContext")
records: Optional[List[OtherExamRecord]] = Field(alias="records")
class UnifiedExamInfo(BaseModel):
"""统一考试信息模型 - 对外提供的统一格式"""
course_name: str = Field("", description="课程名称")
exam_date: str = Field("", description="考试日期")
exam_time: str = Field("", description="考试时间")
exam_location: str = Field("", description="考试地点")
exam_type: str = Field("", description="考试类型")
note: str = Field("", description="备注")
class ExamInfoResponse(BaseModel):
"""考试信息统一响应模型"""
exams: List[UnifiedExamInfo] = Field(
default_factory=list, description="考试信息列表"
)
total_count: int = Field(0, description="考试总数")
class SeatInfo(BaseModel):
"""座位信息模型"""
course_name: str = Field("", description="课程名称")
seat_number: str = Field("", description="座位号")

View File

@@ -0,0 +1,348 @@
import re
from typing import List, Optional
from pydantic import BaseModel, Field
class PlanCompletionCourse(BaseModel):
"""培养方案课程完成情况"""
flag_id: str = Field("", description="课程标识ID")
flag_type: str = Field("", description="节点类型001=分类, 002=子分类, kch=课程")
course_code: str = Field("", description="课程代码,如 PDA2121005")
course_name: str = Field("", description="课程名称")
is_passed: bool = Field(False, description="是否通过基于CSS图标解析")
status_description: str = Field("", description="状态描述:未修读/已通过/未通过")
credits: Optional[float] = Field(None, description="学分")
score: Optional[str] = Field(None, description="成绩")
exam_date: Optional[str] = Field(None, description="考试日期")
course_type: str = Field("", description="课程类型:必修/任选等")
parent_id: str = Field("", description="父节点ID")
level: int = Field(0, description="层级0=根分类1=子分类2=课程")
@classmethod
def from_ztree_node(cls, node: dict) -> "PlanCompletionCourse":
"""从 zTree 节点数据创建课程对象"""
# 解析name字段中的信息
name = node.get("name", "")
flag_id = node.get("flagId", "")
flag_type = node.get("flagType", "")
parent_id = node.get("pId", "")
# 根据CSS图标判断通过状态
is_passed = False
status_description = "未修读"
if "fa-smile-o fa-1x green" in name:
is_passed = True
status_description = "已通过"
elif "fa-meh-o fa-1x light-grey" in name:
is_passed = False
status_description = "未修读"
elif "fa-frown-o fa-1x red" in name:
is_passed = False
status_description = "未通过"
# 从name中提取纯文本内容
# 移除HTML标签和图标
clean_name = re.sub(r"<[^>]*>", "", name)
clean_name = re.sub(r"&nbsp;", " ", clean_name).strip()
# 解析课程信息
course_code = ""
course_name = ""
credits = None
score = None
exam_date = None
course_type = ""
if flag_type == "kch": # 课程节点
# 解析课程代码:[PDA2121005]形势与政策
code_match = re.search(r"\[([^\]]+)\]", clean_name)
if code_match:
course_code = code_match.group(1)
remaining_text = clean_name.split("]", 1)[1].strip()
# 解析学分信息:[0.3学分]
credit_match = re.search(r"\[([0-9.]+)学分\]", remaining_text)
if credit_match:
credits = float(credit_match.group(1))
remaining_text = re.sub(
r"\[[0-9.]+学分\]", "", remaining_text
).strip()
# 处理复杂的括号内容
# 例如85.0(20250626 成绩,都没把日期解析上,中国近现代史纲要)
# 或者:(任选,87.0(20250119))
# 找到最外层的括号
paren_match = re.search(
r"\(([^)]+(?:\([^)]*\)[^)]*)*)\)$", remaining_text
)
if paren_match:
paren_content = paren_match.group(1)
course_name_candidate = re.sub(
r"\([^)]+(?:\([^)]*\)[^)]*)*\)$", "", remaining_text
).strip()
# 检查括号内容的格式
if "" in paren_content:
# 处理包含中文逗号的复杂格式
parts = paren_content.split("")
# 最后一部分可能是课程名
last_part = parts[-1].strip()
if (
re.search(r"[\u4e00-\u9fff]", last_part)
and len(last_part) > 1
):
# 最后一部分包含中文,很可能是真正的课程名
course_name = last_part
# 从前面的部分提取成绩和其他信息
remaining_parts = "".join(parts[:-1])
# 提取成绩
score_match = re.search(r"([0-9.]+)", remaining_parts)
if score_match:
score = score_match.group(1)
# 提取日期
date_match = re.search(r"(\d{8})", remaining_parts)
if date_match:
exam_date = date_match.group(1)
# 提取课程类型(如果有的话)
if len(parts) > 2:
potential_type = parts[0].strip()
if not re.search(r"[0-9.]", potential_type):
course_type = potential_type
else:
# 最后一部分不是课程名,使用括号外的内容
course_name = (
course_name_candidate
if course_name_candidate
else "未知课程"
)
# 从整个括号内容提取信息
score_match = re.search(r"([0-9.]+)", paren_content)
if score_match:
score = score_match.group(1)
date_match = re.search(r"(\d{8})", paren_content)
if date_match:
exam_date = date_match.group(1)
elif "," in paren_content:
# 处理标准格式:(任选,87.0(20250119))
type_score_parts = paren_content.split(",", 1)
if len(type_score_parts) == 2:
course_type = type_score_parts[0].strip()
score_info = type_score_parts[1].strip()
# 解析成绩和日期
score_date_match = re.search(
r"([0-9.]+)\((\d{8})\)", score_info
)
if score_date_match:
score = score_date_match.group(1)
exam_date = score_date_match.group(2)
else:
score_match = re.search(r"([0-9.]+)", score_info)
if score_match:
score = score_match.group(1)
# 使用括号外的内容作为课程名
course_name = (
course_name_candidate
if course_name_candidate
else "未知课程"
)
else:
# 括号内只有简单内容
course_name = (
course_name_candidate
if course_name_candidate
else "未知课程"
)
# 尝试从括号内容提取成绩
score_match = re.search(r"([0-9.]+)", paren_content)
if score_match:
score = score_match.group(1)
# 尝试提取日期
date_match = re.search(r"(\d{8})", paren_content)
if date_match:
exam_date = date_match.group(1)
else:
# 没有括号,直接使用剩余文本作为课程名
course_name = remaining_text
# 清理课程名
course_name = re.sub(r"\s+", " ", course_name).strip()
course_name = course_name.strip(",。.")
# 如果课程名为空或太短,尝试从原始名称提取
if not course_name or len(course_name) < 2:
chinese_match = re.search(
r"[\u4e00-\u9fff]+(?:[\u4e00-\u9fff\s]*[\u4e00-\u9fff]+)*",
clean_name,
)
if chinese_match:
course_name = chinese_match.group(0).strip()
else:
course_name = clean_name
else:
# 分类节点
course_name = clean_name
# 清理分类名称中的多余括号,但保留重要信息
# 如果是包含学分信息的分类名,保留学分信息
if not re.search(r"学分", course_name):
# 删除分类名称中的统计信息括号,如 "通识通修(已完成20.0/需要20.0)"
course_name = re.sub(r"\([^)]*完成[^)]*\)", "", course_name).strip()
# 删除其他可能的统计括号
course_name = re.sub(
r"\([^)]*\d+\.\d+/[^)]*\)", "", course_name
).strip()
# 清理多余的空格和空括号
course_name = re.sub(r"\(\s*\)", "", course_name).strip()
course_name = re.sub(r"\s+", " ", course_name).strip()
# 确定层级
level = 0
if flag_type == "002":
level = 1
elif flag_type == "kch":
level = 2
return cls(
flag_id=flag_id,
flag_type=flag_type,
course_code=course_code,
course_name=course_name,
is_passed=is_passed,
status_description=status_description,
credits=credits,
score=score,
exam_date=exam_date,
course_type=course_type,
parent_id=parent_id,
level=level,
)
class PlanCompletionCategory(BaseModel):
"""培养方案分类完成情况"""
category_id: str = Field("", description="分类ID")
category_name: str = Field("", description="分类名称")
min_credits: float = Field(0.0, description="最低修读学分")
completed_credits: float = Field(0.0, description="通过学分")
total_courses: int = Field(0, description="已修课程门数")
passed_courses: int = Field(0, description="已及格课程门数")
failed_courses: int = Field(0, description="未及格课程门数")
missing_required_courses: int = Field(0, description="必修课缺修门数")
subcategories: List["PlanCompletionCategory"] = Field(
default_factory=list, description="子分类"
)
courses: List[PlanCompletionCourse] = Field(
default_factory=list, description="课程列表"
)
@classmethod
def from_ztree_node(cls, node: dict) -> "PlanCompletionCategory":
"""从 zTree 节点创建分类对象"""
name = node.get("name", "")
flag_id = node.get("flagId", "")
# 移除HTML标签获取纯文本
clean_name = re.sub(r"<[^>]*>", "", name)
clean_name = re.sub(r"&nbsp;", " ", clean_name).strip()
# 解析分类统计信息
# 格式:通识通修(最低修读学分:68,通过学分:34.4,已修课程门数:26,已及格课程门数:26,未及格课程门数:0,必修课缺修门数:12)
stats_match = re.search(
r"([^(]+)\(最低修读学分:([0-9.]+),通过学分:([0-9.]+),已修课程门数:(\d+),已及格课程门数:(\d+),未及格课程门数:(\d+),必修课缺修门数:(\d+)\)",
clean_name,
)
if stats_match:
category_name = stats_match.group(1)
min_credits = float(stats_match.group(2))
completed_credits = float(stats_match.group(3))
total_courses = int(stats_match.group(4))
passed_courses = int(stats_match.group(5))
failed_courses = int(stats_match.group(6))
missing_required_courses = int(stats_match.group(7))
else:
# 子分类可能没有完整的统计信息
category_name = clean_name
min_credits = 0.0
completed_credits = 0.0
total_courses = 0
passed_courses = 0
failed_courses = 0
missing_required_courses = 0
return cls(
category_id=flag_id,
category_name=category_name,
min_credits=min_credits,
completed_credits=completed_credits,
total_courses=total_courses,
passed_courses=passed_courses,
failed_courses=failed_courses,
missing_required_courses=missing_required_courses,
)
class PlanCompletionInfo(BaseModel):
"""培养方案完成情况总信息"""
plan_name: str = Field("", description="培养方案名称")
major: str = Field("", description="专业名称")
grade: str = Field("", description="年级")
categories: List[PlanCompletionCategory] = Field(
default_factory=list, description="分类列表"
)
total_categories: int = Field(0, description="总分类数")
total_courses: int = Field(0, description="总课程数")
passed_courses: int = Field(0, description="已通过课程数")
failed_courses: int = Field(0, description="未通过课程数")
unread_courses: int = Field(0, description="未修读课程数")
def calculate_statistics(self):
"""计算统计信息"""
total_courses = 0
passed_courses = 0
failed_courses = 0
unread_courses = 0
def count_courses(categories: List[PlanCompletionCategory]):
nonlocal total_courses, passed_courses, failed_courses, unread_courses
for category in categories:
for course in category.courses:
total_courses += 1
if course.is_passed:
passed_courses += 1
elif course.status_description == "未通过":
failed_courses += 1
else:
unread_courses += 1
# 递归处理子分类
count_courses(category.subcategories)
count_courses(self.categories)
self.total_categories = len(self.categories)
self.total_courses = total_courses
self.passed_courses = passed_courses
self.failed_courses = failed_courses
self.unread_courses = unread_courses

View File

@@ -0,0 +1,49 @@
from typing import List
from pydantic import BaseModel, Field
class TimeSlot(BaseModel):
"""时间段模型"""
session: int = Field(..., description="节次")
session_name: str = Field(..., description="节次名称")
start_time: str = Field(..., description="开始时间格式HHMM")
end_time: str = Field(..., description="结束时间格式HHMM")
time_length: str = Field(..., description="时长(分钟)")
djjc: int = Field(..., description="大节节次")
class CourseTimeLocation(BaseModel):
"""课程时间地点模型"""
class_day: int = Field(..., description="上课星期几1-7")
class_sessions: int = Field(..., description="上课节次")
continuing_session: int = Field(..., description="持续节次数")
class_week: str = Field(..., description="上课周次24位二进制字符串")
week_description: str = Field(..., description="上课周次描述")
campus_name: str = Field(..., description="校区名称")
teaching_building_name: str = Field(..., description="教学楼名称")
classroom_name: str = Field(..., description="教室名称")
class ScheduleCourse(BaseModel):
"""课表课程模型"""
course_name: str = Field(..., description="课程名称")
course_code: str = Field(..., description="课程代码")
course_sequence: str = Field(..., description="课程序号")
teacher_name: str = Field(..., description="授课教师")
course_properties: str = Field(..., description="课程性质")
exam_type: str = Field(..., description="考试类型")
unit: float = Field(..., description="学分")
time_locations: List[CourseTimeLocation] = Field(..., description="时间地点列表")
is_no_schedule: bool = Field(False, description="是否无具体时间安排")
class ScheduleData(BaseModel):
"""课表数据模型"""
total_units: float = Field(..., description="总学分")
time_slots: List[TimeSlot] = Field(..., description="时间段列表")
courses: List[ScheduleCourse] = Field(..., description="课程列表")

View File

@@ -0,0 +1,28 @@
from typing import List, Optional
from pydantic import BaseModel, Field
class ScoreRecord(BaseModel):
"""成绩记录模型"""
sequence: int = Field(0, description="序号")
term_id: str = Field("", description="学期ID")
course_code: str = Field("", description="课程代码")
course_class: str = Field("", description="课程班级")
course_name_cn: str = Field("", description="课程名称(中文)")
course_name_en: str = Field("", description="课程名称(英文)")
credits: str = Field("", description="学分")
hours: int = Field(0, description="学时")
course_type: Optional[str] = Field(None, description="课程性质")
exam_type: Optional[str] = Field(None, description="考试性质")
score: str = Field("", description="成绩")
retake_score: Optional[str] = Field(None, description="重修成绩")
makeup_score: Optional[str] = Field(None, description="补考成绩")
class TermScoreResponse(BaseModel):
"""学期成绩响应模型"""
total_count: int = Field(0, description="总记录数")
records: List[ScoreRecord] = Field(default_factory=list, description="成绩记录列表")

View File

@@ -0,0 +1,20 @@
from pydantic import BaseModel, Field
class TermItem(BaseModel):
"""学期信息项"""
term_code: str = Field(..., description="学期代码")
term_name: str = Field(..., description="学期名称")
is_current: bool = Field(..., description="是否为当前学期")
class CurrentTermInfo(BaseModel):
"""学期周数信息"""
academic_year: str = Field("", description="学年,如 2025-2026")
current_term_name: str = Field("", description="学期,如 秋、春")
week_number: int = Field(0, description="当前周数")
start_at: str = Field("", description="学期开始时间,格式 YYYY-MM-DD")
is_end: bool = Field(False, description="是否为学期结束")
weekday: int = Field(0, description="星期几")

View File

@@ -0,0 +1,262 @@
import re
import ujson
from bs4 import BeautifulSoup
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from httpx import HTTPError
from pydantic import ValidationError
from ujson import JSONDecodeError
from loveace.router.endpoint.jwc.model.base import JWCConfig
from loveace.router.endpoint.jwc.model.plan import (
PlanCompletionCategory,
PlanCompletionInfo,
)
from loveace.router.endpoint.jwc.utils.plan import populate_category_children
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
ENDPOINT = {
"plan": "/student/integratedQuery/planCompletion/index",
}
jwc_plan_router = APIRouter(
prefix="/plan",
responses=ProtectRouterErrorToCode().gen_code_table(),
)
@jwc_plan_router.get(
"/current",
summary="获取当前培养方案完成信息",
response_model=UniResponseModel[PlanCompletionInfo],
)
async def get_current_plan_completion(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[PlanCompletionInfo] | JSONResponse:
"""
获取用户的培养方案完成情况
✅ 功能特性:
- 获取培养方案的总体完成进度
- 按类别显示各类课程的完成情况
- 显示已完成、未完成、可选课程等
💡 使用场景:
- 查看毕业要求的完成进度
- 了解还需要修读哪些课程
- 规划后续选课
Returns:
PlanCompletionInfo: 包含方案完成情况和各类别详情
"""
try:
conn.logger.info("获取当前培养方案完成信息")
response = await conn.client.get(
JWCConfig().to_full_url(ENDPOINT["plan"]),
follow_redirects=True,
timeout=600,
)
if response.status_code != 200:
conn.logger.error(f"获取培养方案信息失败,状态码: {response.status_code}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
html_content = response.text
# 使用BeautifulSoup解析HTML
soup = BeautifulSoup(html_content, "lxml")
# 提取培养方案名称
plan_name = ""
# 查找包含"培养方案"的h4标签
h4_elements = soup.find_all("h4")
for h4 in h4_elements:
text = h4.get_text(strip=True) if h4 else ""
if "培养方案" in text:
plan_name = text
conn.logger.info(f"找到培养方案标题: {plan_name}")
break
# 解析专业和年级信息
major = ""
grade = ""
if plan_name:
grade_match = re.search(r"(\d{4})级", plan_name)
if grade_match:
grade = grade_match.group(1)
major_match = re.search(r"\d{4}级(.+?)本科", plan_name)
if major_match:
major = major_match.group(1)
# 查找zTree数据
ztree_data = []
# 在script标签中查找zTree初始化数据
scripts = soup.find_all("script")
for script in scripts:
try:
script_text = script.get_text() if script else ""
if "$.fn.zTree.init" in script_text and "flagId" in script_text:
conn.logger.info("找到包含zTree初始化的script标签")
# 提取zTree数据
# 尝试多种模式匹配
patterns = [
r'\$\.fn\.zTree\.init\(\$\("#treeDemo"\),\s*setting,\s*(\[.*?\])\s*\);',
r"\.zTree\.init\([^,]+,\s*[^,]+,\s*(\[.*?\])\s*\);",
r'init\(\$\("#treeDemo"\)[^,]*,\s*[^,]*,\s*(\[.*?\])',
]
json_part = None
for pattern in patterns:
match = re.search(pattern, script_text, re.DOTALL)
if match:
json_part = match.group(1)
conn.logger.info(
f"使用模式匹配成功提取zTree数据: {len(json_part)}字符"
)
break
if json_part:
# 清理和修复JSON格式
# 移除JavaScript注释和多余的逗号
json_part = re.sub(r"//.*?\n", "\n", json_part)
json_part = re.sub(r"/\*.*?\*/", "", json_part, flags=re.DOTALL)
json_part = re.sub(r",\s*}", "}", json_part)
json_part = re.sub(r",\s*]", "]", json_part)
try:
ztree_data = ujson.loads(json_part)
conn.logger.info(f"JSON解析成功{len(ztree_data)}个节点")
break
except JSONDecodeError as e:
conn.logger.warning(f"JSON解析失败: {str(e)}")
# 如果JSON解析失败不使用手动解析直接跳过
continue
else:
conn.logger.warning("未能通过模式匹配提取zTree数据")
continue
except Exception:
continue
if not ztree_data:
conn.logger.warning("未找到有效的zTree数据")
# 输出调试信息
conn.logger.info(f"HTML内容长度: {len(html_content)}")
conn.logger.info(f"找到的script标签数量: {len(soup.find_all('script'))}")
# 检查是否包含关键词
contains_ztree = "zTree" in html_content
contains_flagid = "flagId" in html_content
contains_plan = "培养方案" in html_content
conn.logger.info(
f"HTML包含关键词: zTree={contains_ztree}, flagId={contains_flagid}, 培养方案={contains_plan}"
)
conn.logger.warning("未找到有效的zTree数据")
if contains_plan:
conn.logger.warning(
"检测到培养方案内容但zTree数据解析失败可能页面结构已变化"
)
else:
conn.logger.warning(
"未检测到培养方案相关内容,可能需要重新登录或检查访问权限"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id,
message="未找到有效的培养方案数据,请检查登录状态或稍后再试",
)
# 解析zTree数据构建分类和课程信息
try:
# 按层级组织数据
nodes_by_id = {node["id"]: node for node in ztree_data}
root_categories = []
# 统计根分类和所有节点信息,用于调试
all_parent_ids = set()
root_nodes = []
for node in ztree_data:
parent_id = node.get("pId", "")
all_parent_ids.add(parent_id)
# 根分类的判断条件pId为"-1"这是zTree中真正的根节点标识
# 从HTML示例可以看出真正的根分类的pId是"-1"
is_root_category = parent_id == "-1"
if is_root_category:
root_nodes.append(node)
conn.logger.info(
f"zTree数据分析: 总节点数={len(ztree_data)}, 根节点数={len(root_nodes)}, 不同父ID数={len(all_parent_ids)}"
)
conn.logger.debug(f"所有父ID: {sorted(all_parent_ids)}")
# 构建分类树
for node in root_nodes:
category = PlanCompletionCategory.from_ztree_node(node)
# 填充分类的子分类和课程(支持多层嵌套)
try:
populate_category_children(category, node["id"], nodes_by_id, conn)
except Exception as e:
conn.logger.error(f"填充分类子项异常: {str(e)}")
conn.logger.error(
f"异常节点信息: category_id={node['id']}, 错误详情: {str(e)}"
)
root_categories.append(category)
conn.logger.info(
f"创建根分类: {category.category_name} (ID: {node['id']})"
)
# 创建完成情况信息
completion_info = PlanCompletionInfo(
plan_name=plan_name,
major=major,
grade=grade,
categories=root_categories,
total_categories=0,
total_courses=0,
passed_courses=0,
failed_courses=0,
unread_courses=0,
)
# 计算统计信息
completion_info.calculate_statistics()
conn.logger.info(
f"培养方案完成信息统计: 分类数={completion_info.total_categories}, 课程数={completion_info.total_courses}, 已过课程={completion_info.passed_courses}, 未过课程={completion_info.failed_courses}, 未修读课程={completion_info.unread_courses}"
)
return UniResponseModel[PlanCompletionInfo](
success=True,
data=completion_info,
message="获取培养方案完成信息成功",
error=None,
)
except ValidationError as ve:
conn.logger.error(f"数据验证错误: {ve}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)
except HTTPError as he:
conn.logger.error(f"HTTP请求错误: {he}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)

View File

@@ -0,0 +1,246 @@
import asyncio
import re
from bs4 import BeautifulSoup
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from loveace.router.endpoint.jwc.model.base import JWCConfig
from loveace.router.endpoint.jwc.model.schedule import ScheduleData
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
jwc_schedules_router = APIRouter(
prefix="/schedule",
responses=ProtectRouterErrorToCode.gen_code_table(),
)
ENDPOINTS = {
"student_schedule_pre": "/student/courseSelect/calendarSemesterCurriculum/index",
"student_schedule": "/student/courseSelect/thisSemesterCurriculum/{dynamic_path}/ajaxStudentSchedule/past/callback",
"section_and_time": "/ajax/getSectionAndTime",
}
@jwc_schedules_router.get(
"/{term_code}/table",
summary="获取课表信息",
response_model=UniResponseModel[ScheduleData],
)
async def get_schedule_table(
term_code: str, conn: AUFEConnection = Depends(get_aufe_conn)
) -> UniResponseModel[ScheduleData] | JSONResponse:
"""
获取指定学期的课程表
✅ 功能特性:
- 获取指定学期的完整课程表
- 显示课程名称、教室、时间、教师等信息
- 支持按周查询
💡 使用场景:
- 查看本周课程安排
- 了解完整学期课程表
- 课表分享和导出
Args:
term_code: 学期代码2023-2024-1
Returns:
ScheduleData: 包含课程表数据和课程详情
"""
try:
conn.logger.info(f"开始获取学期 {term_code} 的课表信息")
# 第一步:访问课表预备页面,获取动态路径
dynamic_page = JWCConfig().to_full_url(ENDPOINTS["student_schedule_pre"])
dynamic_page_response = await conn.client.get(
dynamic_page, follow_redirects=True, timeout=conn.timeout
)
if dynamic_page_response.status_code != 200:
conn.logger.error(
f"获取课表预备页面失败,状态码: {dynamic_page_response.status_code}"
)
return ProtectRouterErrorToCode.remote_service_error.to_json_response(
conn.logger.trace_id
)
soup = BeautifulSoup(dynamic_page_response.text, "lxml")
# 尝试从页面中提取动态路径
scripts = soup.find_all("script")
dynamic_path = "B2RMNJkT95" # 默认值
for script in scripts:
try:
script_text = script.string # type: ignore
if script_text and "ajaxStudentSchedule" in script_text:
# 使用正则表达式提取路径
match = re.search(
r"/([A-Za-z0-9]+)/ajaxStudentSchedule", script_text
)
if match:
dynamic_path = match.group(1)
break
except AttributeError:
continue
section_and_time_headers = {
**conn.client.headers,
"Referer": JWCConfig().to_full_url(ENDPOINTS["student_schedule"]),
}
select_and_time_url = JWCConfig().to_full_url(ENDPOINTS["section_and_time"])
select_and_time_data = {
"planNumber": "",
"ff": "f",
"sf_request_type": "ajax",
}
section_and_time_response_coro = conn.client.post(
select_and_time_url,
data=select_and_time_data,
headers=section_and_time_headers,
follow_redirects=True,
timeout=conn.timeout,
)
student_schedule_url = JWCConfig().to_full_url(
ENDPOINTS["student_schedule"].format(dynamic_path=dynamic_path)
)
schedule_params = {
"planCode": term_code,
"sf_request_type": "ajax",
}
student_schedule_response_coro = conn.client.get(
student_schedule_url,
params=schedule_params,
follow_redirects=True,
timeout=conn.timeout,
)
section_and_time_response, student_schedule_response = await asyncio.gather(
section_and_time_response_coro, student_schedule_response_coro
)
if section_and_time_response.status_code != 200:
conn.logger.error(
f"获取节次时间信息失败,状态码: {section_and_time_response.status_code}"
)
return ProtectRouterErrorToCode.remote_service_error.to_json_response(
conn.logger.trace_id, message="无法获取节次时间信息,请稍后再试"
)
if student_schedule_response.status_code != 200:
conn.logger.error(
f"获取课表信息失败,状态码: {student_schedule_response.status_code}"
)
return ProtectRouterErrorToCode.remote_service_error.to_json_response(
conn.logger.trace_id, message="无法获取课表信息,请稍后再试"
)
time_data = section_and_time_response.json()
schedule_data = student_schedule_response.json()
# 处理时间段信息
time_slots = []
section_time = time_data.get("sectionTime", [])
for time_slot in section_time:
time_slots.append(
{
"session": time_slot.get("id", {}).get("session", 0),
"session_name": time_slot.get("sessionName", ""),
"start_time": time_slot.get("startTime", ""),
"end_time": time_slot.get("endTime", ""),
"time_length": time_slot.get("timeLength", ""),
"djjc": time_slot.get("djjc", 0),
}
)
# 处理课程信息
courses = []
xkxx_list = schedule_data.get("xkxx", [])
for xkxx_item in xkxx_list:
if isinstance(xkxx_item, dict):
for course_key, course_data in xkxx_item.items():
if isinstance(course_data, dict):
# 提取基本课程信息
course_name = course_data.get("courseName", "")
course_code = course_data.get("id", {}).get("coureNumber", "")
course_sequence = course_data.get("id", {}).get(
"coureSequenceNumber", ""
)
teacher_name = (
course_data.get("attendClassTeacher", "")
.replace("* ", "")
.strip()
)
course_properties = course_data.get("coursePropertiesName", "")
exam_type = course_data.get("examTypeName", "")
unit = float(course_data.get("unit", 0))
# 处理时间地点列表
time_locations = []
time_place_list = course_data.get("timeAndPlaceList", [])
# 检查是否有具体时间安排
is_no_schedule = len(time_place_list) == 0
for time_place in time_place_list:
# 过滤掉无用的字段,只保留关键信息
time_location = {
"class_day": time_place.get("classDay", 0),
"class_sessions": time_place.get("classSessions", 0),
"continuing_session": time_place.get(
"continuingSession", 0
),
"class_week": time_place.get("classWeek", ""),
"week_description": time_place.get(
"weekDescription", ""
),
"campus_name": time_place.get("campusName", ""),
"teaching_building_name": time_place.get(
"teachingBuildingName", ""
),
"classroom_name": time_place.get("classroomName", ""),
}
time_locations.append(time_location)
# 只保留有效的课程(有课程名称的)
if course_name:
course = {
"course_name": course_name,
"course_code": course_code,
"course_sequence": course_sequence,
"teacher_name": teacher_name,
"course_properties": course_properties,
"exam_type": exam_type,
"unit": unit,
"time_locations": time_locations,
"is_no_schedule": is_no_schedule,
}
courses.append(course)
# 构建最终数据
processed_data = {
"total_units": float(schedule_data.get("allUnits", 0)),
"time_slots": time_slots,
"courses": courses,
}
conn.logger.info(
f"成功处理课表数据:共{len(courses)}门课程,{len(time_slots)}个时间段"
)
result = ScheduleData.model_validate(processed_data)
return UniResponseModel[ScheduleData](
success=True,
data=result,
message="获取课表信息成功",
error=None,
)
except ValidationError as ve:
conn.logger.error(f"数据验证错误: {ve}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id, "数据验证错误"
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id,
)

View File

@@ -0,0 +1,176 @@
import re
from bs4 import BeautifulSoup
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from httpx import HTTPError
from pydantic import ValidationError
from loveace.router.endpoint.jwc.model.base import JWCConfig
from loveace.router.endpoint.jwc.model.score import ScoreRecord, TermScoreResponse
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
jwc_score_router = APIRouter(
prefix="/score",
responses=ProtectRouterErrorToCode().gen_code_table(),
)
ENDPOINT = {
"term_score_pre": "/student/integratedQuery/scoreQuery/allTermScores/index",
"term_score": "/student/integratedQuery/scoreQuery/{dynamic_path}/allTermScores/data",
}
@jwc_score_router.get(
"/{term_code}/list",
summary="获取给定学期成绩列表",
response_model=UniResponseModel[TermScoreResponse],
)
async def get_term_score(
term_code: str,
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[TermScoreResponse] | JSONResponse:
"""
获取指定学期的详细成绩单
✅ 功能特性:
- 获取指定学期所有课程成绩
- 包含补考和重修成绩
- 显示学分、绩点等详细信息
💡 使用场景:
- 查看历史学期的成绩
- 导出成绩单
- 分析学业成绩趋势
Args:
term_code: 学期代码2023-2024-1
Returns:
TermScoreResponse: 包含该学期所有成绩记录和总数
"""
try:
response = await conn.client.get(
JWCConfig().to_full_url(ENDPOINT["term_score_pre"]),
follow_redirects=True,
timeout=conn.timeout,
)
if response.status_code != 200:
conn.logger.error(f"访问成绩查询页面失败,状态码: {response.status_code}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
# 从页面中提取动态路径参数
soup = BeautifulSoup(response.text, "html.parser")
# 查找表单或Ajax请求的URL
# 通常在JavaScript代码中或表单action中
dynamic_path = "M1uwxk14o6" # 默认值,如果无法提取则使用
# 尝试从页面中提取动态路径
scripts = soup.find_all("script")
for script in scripts:
try:
script_text = script.string # type: ignore
if script_text and "allTermScores/data" in script_text:
# 使用正则表达式提取路径
match = re.search(
r"/([A-Za-z0-9]+)/allTermScores/data", script_text
)
if match:
dynamic_path = match.group(1)
break
except AttributeError:
continue
data_url = JWCConfig().to_full_url(
ENDPOINT["term_score"].format(dynamic_path=dynamic_path)
)
data_params = {
"zxjxjhh": term_code,
"kch": "",
"kcm": "",
"pageNum": "1",
"pageSize": "50",
"sf_request_type": "ajax",
}
data_response = await conn.client.post(
data_url,
data=data_params,
follow_redirects=True,
timeout=conn.timeout,
)
if data_response.status_code != 200:
conn.logger.error(f"获取成绩数据失败,状态码: {data_response.status_code}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
data_json = data_response.json()
data_list = data_json.get("list", {})
if not data_list:
result = TermScoreResponse(records=[], total_count=0)
return UniResponseModel[TermScoreResponse](
success=True,
data=result,
message="获取成绩单成功",
error=None,
)
records = data_list.get("records", [])
r_total_count = data_list.get("pageContext", {}).get("totalCount", 0)
term_scores = []
for record in records:
term_scores.append(
ScoreRecord(
sequence=record[0],
term_id=record[1],
course_code=record[2],
course_class=record[3],
course_name_cn=record[4],
course_name_en=record[5],
credits=record[6],
hours=record[7],
course_type=record[8],
exam_type=record[9],
score=record[10],
retake_score=record[11] if record[11] else None,
makeup_score=record[12] if record[12] else None,
)
)
l_total_count = len(term_scores)
assert r_total_count == l_total_count
result = TermScoreResponse(records=term_scores, total_count=r_total_count)
return UniResponseModel[TermScoreResponse](
success=True,
data=result,
message="获取成绩单成功",
error=None,
)
except AssertionError as ae:
conn.logger.error(f"数据属性错误: {ae}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except IndexError as ie:
conn.logger.error(f"数据解析错误: {ie}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except ValidationError as ve:
conn.logger.error(f"数据验证错误: {ve}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except HTTPError as he:
conn.logger.error(f"HTTP请求错误: {he}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)

View File

@@ -0,0 +1,306 @@
import re
from datetime import datetime
from bs4 import BeautifulSoup
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from httpx import HTTPError
from pydantic import ValidationError
from loveace.router.endpoint.jwc.model.base import JWCConfig
from loveace.router.endpoint.jwc.model.term import CurrentTermInfo, TermItem
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
jwc_term_router = APIRouter(
prefix="/term",
responses=ProtectRouterErrorToCode().gen_code_table(),
)
ENDPOINT = {
"all_terms": "/student/courseSelect/calendarSemesterCurriculum/index",
"calendar": "/indexCalendar",
}
@jwc_term_router.get(
"/all",
summary="获取所有学期信息",
response_model=UniResponseModel[list[TermItem]],
)
async def get_all_terms(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[list[TermItem]] | JSONResponse:
"""
获取用户可选的所有学期列表
✅ 功能特性:
- 获取从入学至今的所有学期
- 标记当前学期
- 学期名称格式统一处理
💡 使用场景:
- 选课系统的学期选择菜单
- 成绩查询的学期选择
- 课程表查询的学期选择
Returns:
list[TermItem]: 学期列表,包含学期代码、名称、是否为当前学期
"""
try:
all_terms = []
response = await conn.client.get(
JWCConfig().to_full_url(ENDPOINT["all_terms"]),
follow_redirects=True,
timeout=conn.timeout,
)
if response.status_code != 200:
conn.logger.error(f"获取学期信息失败,状态码: {response.status_code}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
# 解析HTML获取学期选项
soup = BeautifulSoup(response.text, "lxml")
# 查找学期选择下拉框
select_element = soup.find("select", {"id": "planCode"})
if not select_element:
conn.logger.error("未找到学期选择框")
return UniResponseModel[list[TermItem]](
success=False,
data=[],
message="未找到学期选择框",
error=None,
)
terms = {}
# 使用更安全的方式处理选项
try:
options = select_element.find_all("option") # type: ignore
for option in options:
value = option.get("value") # type: ignore
text = option.get_text(strip=True) # type: ignore
# 跳过空值选项(如"全部"
if value and str(value).strip() and text != "全部":
terms[str(value)] = text
except AttributeError:
conn.logger.error("解析学期选项失败")
return UniResponseModel[list[TermItem]](
success=False,
data=[],
message="解析学期选项失败",
error=None,
)
conn.logger.info(f"成功获取{len(terms)}个学期信息")
counter = 0
# 遍历学期选项,提取学期代码和名称
# 将学期中的 "春" 替换为 "下" "秋" 替换为 "上"
for key, value in terms.items():
counter += 1
value = value.replace("", "").replace("", "")
all_terms.append(
TermItem(term_code=key, term_name=value, is_current=counter == 1)
)
return UniResponseModel[list[TermItem]](
success=True,
data=all_terms,
message="获取学期信息成功",
error=None,
)
except ValidationError as ve:
conn.logger.error(f"数据验证错误: {ve}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id
)
except HTTPError as he:
conn.logger.error(f"HTTP请求错误: {he}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)
@jwc_term_router.get(
"/current",
summary="获取当前学期信息",
response_model=UniResponseModel[CurrentTermInfo],
)
async def get_current_term(
conn: AUFEConnection = Depends(get_aufe_conn),
) -> UniResponseModel[CurrentTermInfo] | JSONResponse:
"""
获取当前学期的详细信息
✅ 功能特性:
- 获取当前学期的开始和结束日期
- 获取学期周数信息
- 实时从教务系统获取
💡 使用场景:
- 显示当前学期进度
- 课程表的周次显示参考
- 学期时间提醒
Returns:
CurrentTermInfo: 包含学期代码、名称、开始日期、结束日期等
"""
try:
info_response = await conn.client.get(
JWCConfig().DEFAULT_BASE_URL, follow_redirects=True, timeout=conn.timeout
)
if info_response.status_code != 200:
conn.logger.error(
f"获取学期信息页面失败,状态码: {info_response.status_code}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
start_response = await conn.client.get(
JWCConfig().to_full_url(ENDPOINT["calendar"]),
follow_redirects=True,
timeout=conn.timeout,
)
if start_response.status_code != 200:
conn.logger.error(
f"获取学期开始时间失败,状态码: {start_response.status_code}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
# 提取学期开始时间
flexible_pattern = r'var\s+rq\s*=\s*"(\d{8})";\s*//.*'
match = re.findall(flexible_pattern, start_response.text)
if not match:
conn.logger.error("未找到学期开始时间")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
start_date_str = match[0]
try:
start_date = datetime.strptime(start_date_str, "%Y%m%d").date()
except ValueError:
conn.logger.error(f"学期开始时间格式错误: {start_date_str}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
start_date = datetime.strptime(start_date_str, "%Y%m%d").date()
html_content = info_response.text
# 使用BeautifulSoup解析HTML
soup = BeautifulSoup(html_content, "html.parser")
# 查找包含学期周数信息的元素
# 使用CSS选择器查找
calendar_element = soup.select_one(
"#navbar-container > div.navbar-buttons.navbar-header.pull-right > ul > li.light-red > a"
)
if not calendar_element:
# 如果CSS选择器失败尝试其他方法
# 查找包含"第X周"的元素
potential_elements = soup.find_all("a", class_="dropdown-toggle")
calendar_element = None
for element in potential_elements:
text = element.get_text(strip=True) if element else ""
if "" in text and "" in text:
calendar_element = element
break
# 如果还是找不到,尝试查找任何包含学期信息的元素
if not calendar_element:
all_elements = soup.find_all(text=re.compile(r"\d{4}-\d{4}.*第\d+周"))
if all_elements:
# 找到包含学期信息的文本,查找其父元素
for text_node in all_elements:
parent = text_node.parent
if parent:
calendar_element = parent
break
if not calendar_element:
conn.logger.warning("未找到学期周数信息元素")
# 尝试在整个页面中搜索学期信息模式
semester_pattern = re.search(
r"(\d{4}-\d{4})\s*(春|秋|夏)?\s*第(\d+)周\s*(星期[一二三四五六日天])?",
html_content,
)
if semester_pattern:
calendar_text = semester_pattern.group(0)
conn.logger.info(f"通过正则表达式找到学期信息: {calendar_text}")
else:
conn.logger.debug(f"HTML内容长度: {len(html_content)}")
conn.logger.debug(
"未检测到学期周数相关内容,可能需要重新登录或检查访问权限"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id
)
else:
# 提取文本内容
calendar_text = calendar_element.get_text(strip=True)
conn.logger.info(f"找到学期周数信息: {calendar_text}")
clean_text = re.sub(r"\s+", " ", calendar_text.strip())
# 初始化默认值
academic_year = ""
term = ""
week_number = 0
is_end = False
try:
# 解析学年2025-2026
year_match = re.search(r"(\d{4}-\d{4})", clean_text)
if year_match:
academic_year = year_match.group(1)
# 解析学期:秋、春
semester_match = re.search(r"(春|秋)", clean_text)
if semester_match:
term = semester_match.group(1)
# 解析周数第1周、第15周等
week_match = re.search(r"第(\d+)周", clean_text)
if week_match:
week_number = int(week_match.group(1))
# 判断是否为学期结束通常第16周以后或包含"结束"等关键词)
if week_number >= 16 or "结束" in clean_text or "考试" in clean_text:
is_end = True
except Exception as e:
conn.logger.warning(f"解析学期周数信息时出错: {str(e)}")
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)
result = CurrentTermInfo(
academic_year=academic_year,
current_term_name=term,
week_number=week_number,
start_at=start_date.strftime("%Y-%m-%d"),
is_end=is_end,
weekday=datetime.now().weekday(),
)
return UniResponseModel[CurrentTermInfo](
success=True,
data=result,
message="获取当前学期信息成功",
error=None,
)
except Exception as e:
conn.logger.exception(e)
return ProtectRouterErrorToCode().server_error.to_json_response(
conn.logger.trace_id
)

View File

@@ -0,0 +1,96 @@
"""
ASP.NET 表单解析器
用于从 ASP.NET 页面中提取动态表单数据
"""
import re
from typing import Dict, Optional, Any
from bs4 import BeautifulSoup
class ASPNETFormParser:
"""ASP.NET 表单解析器"""
@staticmethod
def extract_form_data(html_content: str) -> Dict[str, str]:
"""
从 ASP.NET 页面 HTML 中提取表单数据
Args:
html_content: HTML 页面内容
Returns:
包含表单字段的字典
"""
return ASPNETFormParser._extract_with_beautifulsoup(html_content)
@staticmethod
def _extract_with_beautifulsoup(html_content: str) -> Dict[str, str]:
"""
使用 BeautifulSoup 提取表单数据
Args:
html_content: HTML 页面内容
Returns:
包含表单字段的字典
"""
form_data = {}
# 使用 BeautifulSoup 解析 HTML
soup = BeautifulSoup(html_content, "lxml")
# 查找表单
form = soup.find("form", {"method": "post"})
if not form:
raise ValueError("未找到 POST 表单")
# 提取隐藏字段
hidden_fields = [
"__EVENTTARGET",
"__EVENTARGUMENT",
"__LASTFOCUS",
"__VIEWSTATE",
"__VIEWSTATEGENERATOR",
"__EVENTVALIDATION",
]
for field_name in hidden_fields:
input_element = form.find("input", {"name": field_name})
if input_element and input_element.get("value"):
form_data[field_name] = input_element.get("value")
else:
form_data[field_name] = ""
# 添加其他表单字段的默认值
form_data.update(
{
"ctl00$ContentPlaceHolder1$ContentPlaceHolder2$ddlSslb": "%",
"ctl00$ContentPlaceHolder1$ContentPlaceHolder2$txtSsmc": "",
"ctl00$ContentPlaceHolder1$ContentPlaceHolder2$gvSb$ctl28$txtNewPageIndex": "1",
}
)
return form_data
@staticmethod
def get_awards_list_form_data(html_content: str) -> Dict[str, str]:
"""
获取已申报奖项列表页面的表单数据
Args:
html_content: HTML 页面内容
Returns:
用于请求已申报奖项的表单数据
"""
base_form_data = ASPNETFormParser.extract_form_data(html_content)
# 设置 EVENTTARGET 为"已申报奖项"选项卡
base_form_data["__EVENTTARGET"] = (
"ctl00$ContentPlaceHolder1$ContentPlaceHolder2$DataList1$ctl01$LinkButton1"
)
return base_form_data

View File

@@ -0,0 +1,267 @@
from typing import Optional
from bs4 import BeautifulSoup
from loveace.router.endpoint.jwc.model.competition import (
AwardProject,
CompetitionAwardsResponse,
CompetitionCreditsSummaryResponse,
CompetitionFullResponse,
CreditsSummary,
)
class CompetitionInfoParser:
"""
创新创业管理平台信息解析器
功能:
- 解析获奖项目列表(表格数据)
- 解析学分汇总信息
- 提取学生基本信息
"""
def __init__(self, html_content: str):
"""
初始化解析器
参数:
html_content: HTML页面内容字符串
"""
self.soup = BeautifulSoup(html_content, "html.parser")
def parse_awards(self) -> CompetitionAwardsResponse:
"""
解析获奖项目列表
返回:
CompetitionAwardsResponse: 包含获奖项目列表的响应对象
"""
# 解析学生ID
student_id = self._parse_student_id()
# 解析项目列表
projects = self._parse_projects()
response = CompetitionAwardsResponse(
student_id=student_id,
total_count=len(projects),
awards=projects,
)
return response
def parse_credits_summary(self) -> CompetitionCreditsSummaryResponse:
"""
解析学分汇总信息
返回:
CompetitionCreditsSummaryResponse: 包含学分汇总信息的响应对象
"""
# 解析学生ID
student_id = self._parse_student_id()
# 解析学分汇总
credits_summary = self._parse_credits_summary()
response = CompetitionCreditsSummaryResponse(
student_id=student_id,
credits_summary=credits_summary,
)
return response
def parse_full_competition_info(self) -> CompetitionFullResponse:
"""
解析完整的学科竞赛信息(获奖项目 + 学分汇总)
一次性解析HTML同时提取获奖项目列表和学分汇总信息
减少网络IO和数据库查询次数
返回:
CompetitionFullResponse: 包含完整竞赛信息的响应对象
"""
# 解析学生ID
student_id = self._parse_student_id()
# 解析项目列表
projects = self._parse_projects()
# 解析学分汇总
credits_summary = self._parse_credits_summary()
response = CompetitionFullResponse(
student_id=student_id,
total_awards_count=len(projects),
awards=projects,
credits_summary=credits_summary,
)
return response
def _parse_student_id(self) -> str:
"""
解析学生基本信息 - 学生ID/工号
返回:
str: 学生ID如果未找到返回空字符串
"""
student_span = self.soup.find("span", id="ContentPlaceHolder1_lblXM")
if student_span:
text = student_span.get_text(strip=True)
# 格式: "欢迎您20244787"
if "" in text:
return text.split("")[1].strip()
return ""
def _parse_projects(self) -> list:
"""
解析获奖项目列表
数据来源: 页面中ID为 ContentPlaceHolder1_ContentPlaceHolder2_gvHj 的表格
表格结构:
- 第一行为表头
- 后续行为项目数据
- 包含15列数据
返回:
list[AwardProject]: 获奖项目列表
"""
projects = []
# 查找项目列表表格
table = self.soup.find(
"table", id="ContentPlaceHolder1_ContentPlaceHolder2_gvHj"
)
if not table:
return projects
rows = table.find_all("tr")
# 跳过表头行(第一行)
for row in rows[1:]:
cells = row.find_all("td")
if len(cells) < 9: # 至少需要9列数据
continue
try:
project = AwardProject(
project_id=cells[0].get_text(strip=True),
project_name=cells[1].get_text(strip=True),
level=cells[2].get_text(strip=True),
grade=cells[3].get_text(strip=True),
award_date=cells[4].get_text(strip=True),
applicant_id=cells[5].get_text(strip=True),
applicant_name=cells[6].get_text(strip=True),
order=int(cells[7].get_text(strip=True)),
credits=float(cells[8].get_text(strip=True)),
bonus=float(cells[9].get_text(strip=True)),
status=cells[10].get_text(strip=True),
verification_status=cells[11].get_text(strip=True),
)
projects.append(project)
except (ValueError, IndexError):
# 数据格式异常,记录但继续处理
continue
return projects
def _parse_credits_summary(self) -> Optional[CreditsSummary]:
"""
解析学分汇总信息
数据来源: 页面中的学分汇总表中的各类学分 span 元素
提取内容:
- 学科竞赛学分
- 科研项目学分
- 可转竞赛类学分
- 创新创业实践学分
- 能力资格认证学分
- 其他项目学分
返回:
CreditsSummary: 学分汇总对象,如果无法解析则返回 None
"""
discipline_competition_credits = None
scientific_research_credits = None
transferable_competition_credits = None
innovation_practice_credits = None
ability_certification_credits = None
other_project_credits = None
# 查找学科竞赛学分
xkjs_span = self.soup.find(
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblXkjsxf"
)
if xkjs_span:
text = xkjs_span.get_text(strip=True)
discipline_competition_credits = self._parse_credit_value(text)
# 查找科研项目学分
ky_span = self.soup.find(
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblKyxf"
)
if ky_span:
text = ky_span.get_text(strip=True)
scientific_research_credits = self._parse_credit_value(text)
# 查找可转竞赛类学分
kzjsl_span = self.soup.find(
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblKzjslxf"
)
if kzjsl_span:
text = kzjsl_span.get_text(strip=True)
transferable_competition_credits = self._parse_credit_value(text)
# 查找创新创业实践学分
cxcy_span = self.soup.find(
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblCxcyxf"
)
if cxcy_span:
text = cxcy_span.get_text(strip=True)
innovation_practice_credits = self._parse_credit_value(text)
# 查找能力资格认证学分
nlzg_span = self.soup.find(
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblNlzgxf"
)
if nlzg_span:
text = nlzg_span.get_text(strip=True)
ability_certification_credits = self._parse_credit_value(text)
# 查找其他项目学分
qt_span = self.soup.find(
"span", id="ContentPlaceHolder1_ContentPlaceHolder2_lblQtxf"
)
if qt_span:
text = qt_span.get_text(strip=True)
other_project_credits = self._parse_credit_value(text)
return CreditsSummary(
discipline_competition_credits=discipline_competition_credits,
scientific_research_credits=scientific_research_credits,
transferable_competition_credits=transferable_competition_credits,
innovation_practice_credits=innovation_practice_credits,
ability_certification_credits=ability_certification_credits,
other_project_credits=other_project_credits,
)
@staticmethod
def _parse_credit_value(text: str) -> Optional[float]:
"""
解析学分值
参数:
text: 文本值,可能为"0", "16.60", ""
返回:
float: 学分值,如果为""或无法解析则返回 None
"""
text = text.strip()
if text == "" or text == "":
return None
try:
return float(text)
except ValueError:
return None

View File

@@ -0,0 +1,337 @@
import time
from json import JSONDecodeError
from typing import List, Optional
from bs4 import BeautifulSoup
from loveace.router.endpoint.jwc.model.base import JWCConfig
from loveace.router.endpoint.jwc.model.exam import (
ExamInfoResponse,
ExamScheduleItem,
OtherExamRecord,
OtherExamResponse,
SeatInfo,
UnifiedExamInfo,
)
from loveace.service.remote.aufe import AUFEConnection
ENDPOINTS = {
"school_exam_pre_request": "/student/examinationManagement/examPlan/index",
"school_exam_request": "/student/examinationManagement/examPlan/detail",
"seat_info": "/student/examinationManagement/examPlan/index",
"other_exam_record": "/student/examinationManagement/othersExamPlan/queryScores?sf_request_type=ajax",
}
# +++++===== 考试信息前置方法 =====+++++ #
async def fetch_school_exam_schedule(
start_date: str, end_date: str, conn: AUFEConnection
) -> List[ExamScheduleItem]:
"""
获取校统考考试安排
Args:
start_date: 开始日期 (YYYY-MM-DD)
end_date: 结束日期 (YYYY-MM-DD)
Returns:
List[ExamScheduleItem]: 校统考列表
"""
try:
timestamp = int(time.time() * 1000)
headers = {
**conn.client.headers,
"Accept": "application/json, text/javascript, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest",
}
params = {
"start": start_date,
"end": end_date,
"_": str(timestamp),
"sf_request_type": "ajax",
}
await conn.client.get(
url=JWCConfig().to_full_url(ENDPOINTS["school_exam_pre_request"]),
follow_redirects=True,
headers=headers,
timeout=conn.timeout,
)
response = await conn.client.get(
url=JWCConfig().to_full_url(ENDPOINTS["school_exam_request"]),
headers=headers,
params=params,
follow_redirects=True,
timeout=conn.timeout,
)
if response.status_code != 200:
conn.logger.error(f"获取校统考信息失败: HTTP状态码 {response.status_code}")
return []
if "]" == response.text:
conn.logger.warning("获取校统考信息成功,但无数据")
return []
try:
json_data = response.json()
except JSONDecodeError as e:
conn.logger.error(f"解析校统考信息JSON失败: {str(e)}")
return []
# 解析为ExamScheduleItem列表
school_exams = []
if isinstance(json_data, list):
for item in json_data:
exam_item = ExamScheduleItem.model_validate(item)
school_exams.append(exam_item)
conn.logger.info(f"获取校统考信息成功,共 {len(school_exams)} 场考试")
return school_exams
except Exception as e:
conn.logger.error(f"获取校统考信息出现如下异常: {str(e)}")
return []
async def fetch_exam_seat_info(conn: AUFEConnection) -> List[SeatInfo]:
"""
获取考试座位号信息
conn: AUFEConnection
Returns:
List[SeatInfo]: 座位信息列表
"""
try:
headers = {
**conn.client.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",
}
response = await conn.client.get(
url=JWCConfig().to_full_url(ENDPOINTS["seat_info"]),
headers=headers,
follow_redirects=True,
timeout=conn.timeout,
)
if response.status_code != 200:
conn.logger.error(
f"获取考试座位号信息失败: HTTP状态码 {response.status_code}"
)
return []
soup = BeautifulSoup(response.text, "lxml")
seat_infos = []
# 查找所有考试信息区块
exam_blocks = soup.find_all("div", {"class": "widget-box"})
for block in exam_blocks:
course_name = ""
seat_number = ""
# 获取课程名
title = block.find("h5", {"class": "widget-title"}) # type: ignore
if title:
course_text = title.get_text(strip=True) # type: ignore
# 提取课程名,格式可能是: "(课程代码-班号)课程名"
if "" in course_text:
course_name = course_text.split("", 1)[1].strip()
else:
course_name = course_text.strip()
# 获取座位号
widget_main = block.find("div", {"class": "widget-main"}) # type: ignore
if widget_main:
content = widget_main.get_text() # type: ignore
for line in content.split("\n"):
if "座位号" in line:
try:
seat_number = line.split("座位号:")[1].strip()
except Exception:
try:
seat_number = line.split("座位号:")[1].strip()
except Exception:
pass
break
if course_name and seat_number:
seat_infos.append(
SeatInfo(course_name=course_name, seat_number=seat_number)
)
conn.logger.info(f"获取考试座位号信息成功,共 {len(seat_infos)} 条记录")
return seat_infos
except Exception as e:
conn.logger.error(f"获取考试座位号信息异常: {str(e)}")
return []
def convert_school_exam_to_unified(
exam: ExamScheduleItem, seat_infos: List[SeatInfo], conn: AUFEConnection
) -> Optional[UnifiedExamInfo]:
"""
将校统考数据转换为统一格式
Args:
exam: 校统考项目
seat_info: 座位号信息映射
Returns:
Optional[UnifiedExamInfo]: 统一格式的考试信息
"""
try:
# 解析title信息格式如: "新媒体导论\n08:30-10:30\n西校\n西校通慧楼\n通慧楼-308\n"
title_parts = exam.title.strip().split("\n")
if len(title_parts) < 2:
return None
course_name = title_parts[0]
exam_time = title_parts[1] if len(title_parts) > 1 else ""
# 拼接地点信息
location_parts = title_parts[2:] if len(title_parts) > 2 else []
exam_location = " ".join([part for part in location_parts if part.strip()])
# 添加座位号到备注
note = ""
for seat in seat_infos:
if seat.course_name == course_name:
note = f"座位号: {seat.seat_number}"
note = note.removesuffix("准考证号:")
break
return UnifiedExamInfo(
course_name=course_name,
exam_date=exam.start,
exam_time=exam_time,
exam_location=exam_location,
exam_type="校统考",
note=note,
)
except Exception as e:
conn.logger.error(f"转换校统考数据异常: {str(e)}")
return None
async def fetch_other_exam_records(
term_code: str, conn: AUFEConnection
) -> List[OtherExamRecord]:
"""
获取其他考试记录
Args:
term_code: 学期代码
conn: AUFEConnection
Returns:
List: 其他考试记录列表
"""
try:
headers = {
**conn.client.headers,
"Accept": "application/json, text/javascript, */*; q=0.01",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
}
data = {"zxjxjhh": term_code, "tab": "0", "pageNum": "1", "pageSize": "30"}
response = await conn.client.post(
url=JWCConfig().to_full_url(ENDPOINTS["other_exam_record"]),
headers=headers,
data=data,
follow_redirects=True,
timeout=conn.timeout,
)
valid = OtherExamResponse.model_validate_json(response.text)
if valid.records:
conn.logger.info(f"获取其他考试信息成功,共 {len(valid.records)} 条记录")
return valid.records
else:
conn.logger.warning("获取其他考试信息成功,但无记录")
return []
except Exception as e:
conn.logger.error(f"获取其他考试信息出现如下异常: {str(e)}")
return []
def convert_other_exam_to_unified(
record: OtherExamRecord, conn: AUFEConnection
) -> Optional[UnifiedExamInfo]:
"""
将其他考试记录转换为统一格式
Args:
record: 其他考试记录
Returns:
Optional[UnifiedExamInfo]: 统一格式的考试信息
"""
try:
return UnifiedExamInfo(
course_name=record.course_name,
exam_date=record.exam_date,
exam_time=record.exam_time,
exam_location=record.exam_location,
exam_type="其他考试",
note=record.note,
)
except Exception as e:
conn.logger.error(f"转换其他考试数据异常: {str(e)}")
return None
async def fetch_unified_exam_info(
conn: AUFEConnection,
start_date: str,
end_date: str,
term_code: str = "2024-2025-2-1",
) -> ExamInfoResponse:
"""
获取统一的考试信息,包括校统考和其他考试
Args:
start_date: 开始日期 (YYYY-MM-DD)
end_date: 结束日期 (YYYY-MM-DD)
term_code: 学期代码,默认为当前学期
Returns:
ExamInfoResponse: 统一的考试信息响应
"""
try:
# 合并并转换为统一格式
unified_exams = []
# 获取校统考信息
if school_exams := await fetch_school_exam_schedule(start_date, end_date, conn):
# 获取座位号信息
seat_info = await fetch_exam_seat_info(conn)
# 处理校统考数据
for exam in school_exams:
unified_exam = convert_school_exam_to_unified(exam, seat_info, conn)
if unified_exam:
unified_exams.append(unified_exam)
# 获取其他考试信息
other_exams = await fetch_other_exam_records(term_code, conn)
# 处理其他考试数据
for record in other_exams:
unified_exam = convert_other_exam_to_unified(record, conn)
if unified_exam:
unified_exams.append(unified_exam)
# 按考试日期排序
def _sort_key(exam: UnifiedExamInfo) -> str:
return exam.exam_date + " " + exam.exam_time
unified_exams.sort(key=_sort_key)
return ExamInfoResponse(
exams=unified_exams,
total_count=len(unified_exams),
)
except Exception:
raise

View File

@@ -0,0 +1,67 @@
from loveace.router.endpoint.jwc.model.plan import (
PlanCompletionCategory,
PlanCompletionCourse,
)
from loveace.service.remote.aufe import AUFEConnection
def populate_category_children(
category: PlanCompletionCategory,
category_id: str,
nodes_by_id: dict,
conn: AUFEConnection,
):
"""填充分类的子分类和课程(支持多层嵌套)"""
try:
children_count = 0
subcategory_count = 0
course_count = 0
for node in nodes_by_id.values():
if node.get("pId") == category_id:
children_count += 1
flag_type = node.get("flagType", "")
if flag_type in ["001", "002"]: # 分类或子分类
subcategory = PlanCompletionCategory.from_ztree_node(node)
# 递归处理子项,支持多层嵌套
populate_category_children(
subcategory, node["id"], nodes_by_id, conn
)
category.subcategories.append(subcategory)
subcategory_count += 1
elif flag_type == "kch": # 课程
course = PlanCompletionCourse.from_ztree_node(node)
category.courses.append(course)
course_count += 1
else:
# 处理其他类型的节点,也可能是分类
# 根据是否有子节点来判断是分类还是课程
has_children = any(
n.get("pId") == node["id"] for n in nodes_by_id.values()
)
if has_children:
# 有子节点,当作分类处理
subcategory = PlanCompletionCategory.from_ztree_node(node)
populate_category_children(
subcategory, node["id"], nodes_by_id, conn
)
category.subcategories.append(subcategory)
subcategory_count += 1
else:
# 无子节点,当作课程处理
course = PlanCompletionCourse.from_ztree_node(node)
category.courses.append(course)
course_count += 1
if children_count > 0:
conn.logger.info(
f"分类 '{category.category_name}' (ID: {category_id}) 的子项: 总数={children_count}, 子分类={subcategory_count}, 课程={course_count}"
)
except Exception as e:
conn.logger.error(f"填充分类子项异常: {str(e)}")
conn.logger.error(
f"异常节点信息: category_id={category_id}, 错误详情: {str(e)}"
)
raise

View File

@@ -0,0 +1,27 @@
def convert_zxjxjhh_to_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

View File

@@ -0,0 +1,10 @@
from fastapi import APIRouter
from loveace.router.endpoint.ldjlb.labor import ldjlb_labor_router
ldjlb_base_router = APIRouter(
prefix="/ldjlb",
tags=["劳动俱乐部"],
)
ldjlb_base_router.include_router(ldjlb_labor_router)

View File

@@ -0,0 +1,703 @@
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from httpx import Headers, HTTPError
from pydantic import ValidationError
from loveace.router.endpoint.ldjlb.model.base import LDJLBConfig
from loveace.router.endpoint.ldjlb.model.ldjlb import (
ActivityDetailResponse,
LDJLBActivityListResponse,
LDJLBApplyResponse,
LDJLBClubListResponse,
LDJLBProgressInfo,
ScanSignRequest,
ScanSignResponse,
SignListResponse,
)
from loveace.router.endpoint.ldjlb.utils.ldjlb_ticket import get_ldjlb_header
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
ldjlb_labor_router = APIRouter(
prefix="/labor",
responses=ProtectRouterErrorToCode().gen_code_table(),
)
ENDPOINT = {
"progress": "/User/Activity/GetMyFinishCount?sf_request_type=ajax",
"joined_activities": "/User/Activity/DoGetJoinPageList?sf_request_type=ajax",
"joined_clubs": "/User/Club/DoGetJoinList?sf_request_type=ajax",
"club_activities": "/User/Activity/DoGetPageList?sf_request_type=ajax",
"apply_join": "/User/Activity/DoApplyJoin?sf_request_type=ajax",
"scan_sign": "/User/Center/DoScanSignQRImage",
"sign_list": "/User/Activity/DoGetSignList",
"activity_detail": "/User/Activity/DoGetDetail",
}
@ldjlb_labor_router.get(
"/progress",
response_model=UniResponseModel[LDJLBProgressInfo],
summary="获取劳动俱乐部修课进度",
)
async def get_labor_progress(
conn: AUFEConnection = Depends(get_aufe_conn),
headers: Headers = Depends(get_ldjlb_header),
) -> UniResponseModel[LDJLBProgressInfo] | JSONResponse:
"""
获取用户的劳动俱乐部修课进度
✅ 功能特性:
- 获取已完成的劳动活动数量
- 计算修课进度百分比满分10次
- 实时从劳动俱乐部服务获取最新数据
💡 使用场景:
- 个人中心显示劳动修课进度
- 检查是否满足劳动教育要求
- 了解还需完成的活动次数
Returns:
LDJLBProgressInfo: 包含已完成次数和进度百分比
"""
try:
conn.logger.info("开始获取劳动俱乐部修课进度")
response = await conn.client.post(
url=LDJLBConfig().to_full_url(ENDPOINT["progress"]),
data={},
headers=headers,
timeout=6000,
)
if response.status_code != 200:
conn.logger.error(
f"获取劳动俱乐部修课进度失败HTTP状态码: {response.status_code}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取劳动俱乐部修课进度失败,请稍后重试"
)
data = response.json()
if data.get("code") != 0:
conn.logger.error(
f"获取劳动俱乐部修课进度失败,响应代码: {data.get('code')}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取劳动俱乐部修课进度失败,请稍后重试"
)
try:
progress_info = LDJLBProgressInfo.model_validate(data)
conn.logger.info(
f"成功获取劳动俱乐部修课进度: 已完成 {progress_info.finish_count}/10 次"
)
return UniResponseModel[LDJLBProgressInfo](
success=True,
data=progress_info,
message="获取劳动俱乐部修课进度成功",
error=None,
)
except ValidationError as ve:
conn.logger.error(f"解析劳动俱乐部修课进度失败: {str(ve)}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id, "解析劳动俱乐部修课进度失败,请稍后重试"
)
except HTTPError as he:
conn.logger.error(f"获取劳动俱乐部修课进度异常: {str(he)}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取劳动俱乐部修课进度异常,请稍后重试"
)
except Exception as e:
conn.logger.error(f"获取劳动俱乐部修课进度未知异常: {str(e)}")
return ProtectRouterErrorToCode().unknown_error.to_json_response(
conn.logger.trace_id, "获取劳动俱乐部修课进度未知异常,请稍后重试"
)
@ldjlb_labor_router.get(
"/joined/activities",
response_model=UniResponseModel[LDJLBActivityListResponse],
summary="获取已加入的劳动活动列表",
)
async def get_joined_activities(
conn: AUFEConnection = Depends(get_aufe_conn),
headers: Headers = Depends(get_ldjlb_header),
) -> UniResponseModel[LDJLBActivityListResponse] | JSONResponse:
"""
获取用户已加入的劳动活动列表
✅ 功能特性:
- 获取用户已报名的所有劳动活动
- 包含活动状态、时间、负责人等详细信息
- 支持分页查询
💡 使用场景:
- 查看我的劳动活动页面
- 了解已报名活动的详细信息
- 查看活动进度和状态
Returns:
LDJLBActivityListResponse: 包含活动列表和分页信息
"""
try:
conn.logger.info("开始获取已加入的劳动活动列表")
response = await conn.client.post(
url=LDJLBConfig().to_full_url(ENDPOINT["joined_activities"]),
data={},
headers=headers,
timeout=6000,
)
if response.status_code != 200:
conn.logger.error(
f"获取已加入的劳动活动列表失败HTTP状态码: {response.status_code}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取已加入的劳动活动列表失败,请稍后重试"
)
data = response.json()
if data.get("code") != 0:
conn.logger.error(
f"获取已加入的劳动活动列表失败,响应代码: {data.get('code')}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取已加入的劳动活动列表失败,请稍后重试"
)
try:
activity_list = LDJLBActivityListResponse.model_validate(data)
conn.logger.info(
f"成功获取已加入的劳动活动列表,共 {len(activity_list.activities)} 个活动"
)
return UniResponseModel[LDJLBActivityListResponse](
success=True,
data=activity_list,
message="获取已加入的劳动活动列表成功",
error=None,
)
except ValidationError as ve:
conn.logger.error(f"解析已加入的劳动活动列表失败: {str(ve)}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id, "解析已加入的劳动活动列表失败,请稍后重试"
)
except HTTPError as he:
conn.logger.error(f"获取已加入的劳动活动列表异常: {str(he)}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取已加入的劳动活动列表异常,请稍后重试"
)
except Exception as e:
conn.logger.error(f"获取已加入的劳动活动列表未知异常: {str(e)}")
return ProtectRouterErrorToCode().unknown_error.to_json_response(
conn.logger.trace_id, "获取已加入的劳动活动列表未知异常,请稍后重试"
)
@ldjlb_labor_router.get(
"/joined/clubs",
response_model=UniResponseModel[LDJLBClubListResponse],
summary="获取已加入的劳动俱乐部列表",
)
async def get_joined_clubs(
conn: AUFEConnection = Depends(get_aufe_conn),
headers: Headers = Depends(get_ldjlb_header),
) -> UniResponseModel[LDJLBClubListResponse] | JSONResponse:
"""
获取用户已加入的劳动俱乐部列表
✅ 功能特性:
- 获取用户已加入的所有劳动俱乐部
- 包含俱乐部详细信息、负责人、成员数等
- 用于后续查询俱乐部活动
💡 使用场景:
- 查看我的俱乐部页面
- 获取俱乐部ID用于查询活动
- 了解俱乐部详细信息
Returns:
LDJLBClubListResponse: 包含俱乐部列表
"""
try:
conn.logger.info("开始获取已加入的劳动俱乐部列表")
response = await conn.client.post(
url=LDJLBConfig().to_full_url(ENDPOINT["joined_clubs"]),
data={},
headers=headers,
timeout=6000,
)
if response.status_code != 200:
conn.logger.error(
f"获取已加入的劳动俱乐部列表失败HTTP状态码: {response.status_code}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取已加入的劳动俱乐部列表失败,请稍后重试"
)
data = response.json()
if data.get("code") != 0:
conn.logger.error(
f"获取已加入的劳动俱乐部列表失败,响应代码: {data.get('code')}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取已加入的劳动俱乐部列表失败,请稍后重试"
)
try:
club_list = LDJLBClubListResponse.model_validate(data)
conn.logger.info(
f"成功获取已加入的劳动俱乐部列表,共 {len(club_list.clubs)} 个俱乐部"
)
return UniResponseModel[LDJLBClubListResponse](
success=True,
data=club_list,
message="获取已加入的劳动俱乐部列表成功",
error=None,
)
except ValidationError as ve:
conn.logger.error(f"解析已加入的劳动俱乐部列表失败: {str(ve)}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id, "解析已加入的劳动俱乐部列表失败,请稍后重试"
)
except HTTPError as he:
conn.logger.error(f"获取已加入的劳动俱乐部列表异常: {str(he)}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取已加入的劳动俱乐部列表异常,请稍后重试"
)
except Exception as e:
conn.logger.error(f"获取已加入的劳动俱乐部列表未知异常: {str(e)}")
return ProtectRouterErrorToCode().unknown_error.to_json_response(
conn.logger.trace_id, "获取已加入的劳动俱乐部列表未知异常,请稍后重试"
)
@ldjlb_labor_router.get(
"/club/{club_id}/activities",
response_model=UniResponseModel[LDJLBActivityListResponse],
summary="获取指定俱乐部的活动列表",
)
async def get_club_activities(
club_id: str,
page_index: int = 1,
page_size: int = 100,
conn: AUFEConnection = Depends(get_aufe_conn),
headers: Headers = Depends(get_ldjlb_header),
) -> UniResponseModel[LDJLBActivityListResponse] | JSONResponse:
"""
获取指定俱乐部的活动列表
✅ 功能特性:
- 根据俱乐部ID获取该俱乐部的所有活动
- 支持分页查询默认pageSize=100
- 包含活动的详细信息和报名状态
💡 使用场景:
- 浏览某个俱乐部的活动列表
- 查找可报名的劳动活动
- 了解活动详情准备报名
Args:
club_id: 俱乐部ID
page_index: 页码默认1
page_size: 每页大小默认100
Returns:
LDJLBActivityListResponse: 包含活动列表和分页信息
"""
try:
conn.logger.info(f"开始获取俱乐部 {club_id} 的活动列表")
response = await conn.client.post(
url=LDJLBConfig().to_full_url(ENDPOINT["club_activities"])
+ f"?pageIndex={page_index}&pageSize={page_size}&clubID={club_id}",
data={},
headers=headers,
timeout=6000,
)
if response.status_code != 200:
conn.logger.error(
f"获取俱乐部活动列表失败HTTP状态码: {response.status_code}"
)
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取俱乐部活动列表失败,请稍后重试"
)
data = response.json()
if data.get("code") != 0:
conn.logger.error(f"获取俱乐部活动列表失败,响应代码: {data.get('code')}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取俱乐部活动列表失败,请稍后重试"
)
try:
activity_list = LDJLBActivityListResponse.model_validate(data)
conn.logger.info(
f"成功获取俱乐部 {club_id} 的活动列表,共 {len(activity_list.activities)} 个活动"
)
return UniResponseModel[LDJLBActivityListResponse](
success=True,
data=activity_list,
message="获取俱乐部活动列表成功",
error=None,
)
except ValidationError as ve:
conn.logger.error(f"解析俱乐部活动列表失败: {str(ve)}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id, "解析俱乐部活动列表失败,请稍后重试"
)
except HTTPError as he:
conn.logger.error(f"获取俱乐部活动列表异常: {str(he)}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取俱乐部活动列表异常,请稍后重试"
)
except Exception as e:
conn.logger.error(f"获取俱乐部活动列表未知异常: {str(e)}")
return ProtectRouterErrorToCode().unknown_error.to_json_response(
conn.logger.trace_id, "获取俱乐部活动列表未知异常,请稍后重试"
)
@ldjlb_labor_router.post(
"/activity/{activity_id}/apply",
response_model=UniResponseModel[LDJLBApplyResponse],
summary="报名参加劳动活动",
)
async def apply_activity(
activity_id: str,
reason: str = "加入课程",
conn: AUFEConnection = Depends(get_aufe_conn),
headers: Headers = Depends(get_ldjlb_header),
) -> UniResponseModel[LDJLBApplyResponse] | JSONResponse:
"""
报名参加劳动活动
✅ 功能特性:
- 报名参加指定的劳动活动
- 自动提交报名申请
- 返回报名结果
💡 使用场景:
- 用户点击报名按钮
- 批量报名多个活动
- 自动化报名流程
Args:
activity_id: 活动ID
reason: 报名理由,默认"加入课程"
Returns:
LDJLBApplyResponse: 包含报名结果代码和消息
"""
try:
conn.logger.info(f"开始报名活动 {activity_id}")
response = await conn.client.post(
url=LDJLBConfig().to_full_url(ENDPOINT["apply_join"]),
data={"activityID": activity_id, "reason": reason},
headers=headers,
timeout=6000,
)
if response.status_code != 200:
conn.logger.error(f"报名活动失败HTTP状态码: {response.status_code}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "报名活动失败,请稍后重试"
)
data = response.json()
try:
apply_result = LDJLBApplyResponse.model_validate(data)
if apply_result.code == 0:
conn.logger.success(f"成功报名活动 {activity_id}: {apply_result.msg}")
else:
conn.logger.warning(
f"报名活动 {activity_id} 失败: {apply_result.msg} (code: {apply_result.code})"
)
return UniResponseModel[LDJLBApplyResponse](
success=apply_result.code == 0,
data=apply_result,
message=apply_result.msg,
error=None,
)
except ValidationError as ve:
conn.logger.error(f"解析报名响应失败: {str(ve)}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id, "解析报名响应失败,请稍后重试"
)
except HTTPError as he:
conn.logger.error(f"报名活动异常: {str(he)}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "报名活动异常,请稍后重试"
)
except Exception as e:
conn.logger.error(f"报名活动未知异常: {str(e)}")
return ProtectRouterErrorToCode().unknown_error.to_json_response(
conn.logger.trace_id, "报名活动未知异常,请稍后重试"
)
@ldjlb_labor_router.post(
"/scan_sign",
response_model=UniResponseModel[ScanSignResponse],
summary="扫码签到",
)
async def scan_sign_in(
request: ScanSignRequest,
conn: AUFEConnection = Depends(get_aufe_conn),
headers: Headers = Depends(get_ldjlb_header),
) -> UniResponseModel[ScanSignResponse] | JSONResponse:
"""
扫码签到功能
✅ 功能特性:
- 通过扫描二维码进行活动签到
- 支持位置信息验证
- 实时反馈签到结果
Args:
request: 扫码签到请求,包含:
- content: 扫描的二维码内容
- location: 位置信息,格式为"经度,纬度"
Returns:
UniResponseModel[ScanSignResponse]: 包含签到结果
"""
try:
conn.logger.info(f"开始扫码签到,位置: {request.location}")
# 发送POST请求到劳动俱乐部签到接口
response = await conn.client.post(
url=LDJLBConfig().to_full_url(ENDPOINT["scan_sign"]),
json={
"content": request.content,
"location": request.location,
},
headers=headers,
timeout=6000,
)
if response.status_code != 200:
conn.logger.error(f"扫码签到失败HTTP状态码: {response.status_code}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "扫码签到失败,请稍后重试"
)
data = response.json()
try:
sign_result = ScanSignResponse.model_validate(data)
if sign_result.code == 0:
conn.logger.success(f"扫码签到成功: {sign_result.msg}")
else:
conn.logger.warning(
f"扫码签到失败: {sign_result.msg} (code: {sign_result.code})"
)
return UniResponseModel[ScanSignResponse](
success=sign_result.code == 0,
data=sign_result,
message=sign_result.msg or "签到完成",
error=None,
)
except ValidationError as ve:
conn.logger.error(f"解析签到响应失败: {str(ve)}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id, "解析签到响应失败,请稍后重试"
)
except HTTPError as he:
conn.logger.error(f"扫码签到异常: {str(he)}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "扫码签到异常,请稍后重试"
)
except Exception as e:
conn.logger.error(f"扫码签到未知异常: {str(e)}")
return ProtectRouterErrorToCode().unknown_error.to_json_response(
conn.logger.trace_id, "扫码签到未知异常,请稍后重试"
)
@ldjlb_labor_router.get(
"/{activity_id}/sign_list",
response_model=UniResponseModel[SignListResponse],
summary="获取活动签到列表",
)
async def get_sign_list(
activity_id: str,
conn: AUFEConnection = Depends(get_aufe_conn),
headers: Headers = Depends(get_ldjlb_header),
) -> UniResponseModel[SignListResponse] | JSONResponse:
"""
获取指定活动的签到列表
✅ 功能特性:
- 获取活动的所有签到项
- 支持分页查询
- 查看签到状态和时间
- 辅助扫码签到功能
Args:
activity_id: 活动ID
sign_type: 签到类型默认1签到
page_index: 页码从1开始
page_size: 每页大小默认10
Returns:
UniResponseModel[SignListResponse]: 包含签到列表数据
"""
sign_type: int = 1
page_index: int = 1
page_size: int = 10
try:
conn.logger.info(f"开始获取活动 {activity_id} 的签到列表")
# 发送POST请求到劳动俱乐部签到列表接口
response = await conn.client.post(
url=LDJLBConfig().to_full_url(ENDPOINT["sign_list"]),
data={
"activityID": activity_id,
"type": sign_type,
"pageIndex": page_index,
"pageSize": page_size,
},
headers=headers,
timeout=6000,
)
if response.status_code != 200:
conn.logger.error(f"获取签到列表失败HTTP状态码: {response.status_code}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取签到列表失败,请稍后重试"
)
data = response.json()
try:
sign_list_result = SignListResponse.model_validate(data)
if sign_list_result.code == 0:
sign_count = len(sign_list_result.data)
signed_count = sum(1 for item in sign_list_result.data if item.is_sign)
conn.logger.success(
f"成功获取签到列表,共 {sign_count} 项,已签到 {signed_count}"
)
else:
conn.logger.warning(f"获取签到列表失败 (code: {sign_list_result.code})")
return UniResponseModel[SignListResponse](
success=sign_list_result.code == 0,
data=sign_list_result,
message="获取签到列表成功"
if sign_list_result.code == 0
else "获取签到列表失败",
error=None,
)
except ValidationError as ve:
conn.logger.error(f"解析签到列表响应失败: {str(ve)}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id, "解析签到列表响应失败,请稍后重试"
)
except HTTPError as he:
conn.logger.error(f"获取签到列表异常: {str(he)}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取签到列表异常,请稍后重试"
)
except Exception as e:
conn.logger.error(f"获取签到列表未知异常: {str(e)}")
return ProtectRouterErrorToCode().unknown_error.to_json_response(
conn.logger.trace_id, "获取签到列表未知异常,请稍后重试"
)
@ldjlb_labor_router.get(
"/{activity_id}/detail",
response_model=UniResponseModel[ActivityDetailResponse],
summary="获取活动详情",
)
async def get_activity_detail(
activity_id: str,
conn: AUFEConnection = Depends(get_aufe_conn),
headers: Headers = Depends(get_ldjlb_header),
) -> UniResponseModel[ActivityDetailResponse] | JSONResponse:
"""
获取活动详细信息
✅ 功能特性:
- 获取活动完整信息(标题、时间、地点等)
- 查看活动地址和教室信息
- 查看报名人数和限制
- 查看审批流程和教师列表
- 支持扫码签到功能的前置查询
Args:
activity_id: 活动ID
Returns:
UniResponseModel[ActivityDetailResponse]: 包含活动详细信息
说明:
- formData 中包含"活动地址"等关键信息(如教室位置)
- flowData 包含审批流程记录
- teacherList 包含活动相关教师信息
"""
try:
conn.logger.info(f"开始获取活动详情: {activity_id}")
# 发送POST请求到劳动俱乐部活动详情接口
response = await conn.client.post(
url=LDJLBConfig().to_full_url(ENDPOINT["activity_detail"]),
data={"id": activity_id},
headers=headers,
timeout=6000,
)
if response.status_code != 200:
conn.logger.error(f"获取活动详情失败HTTP状态码: {response.status_code}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取活动详情失败,请稍后重试"
)
data = response.json()
try:
detail_result = ActivityDetailResponse.model_validate(data)
if detail_result.code == 0 and detail_result.data:
# 提取关键信息用于日志
activity_title = detail_result.data.title
activity_location = "未知"
# 从 formData 中提取活动地址
for field in detail_result.form_data:
if field.name == "活动地址" and field.value:
activity_location = field.value
break
conn.logger.success(
f"成功获取活动详情 - 标题: {activity_title}, 地点: {activity_location}"
)
else:
conn.logger.warning(f"获取活动详情失败 (code: {detail_result.code})")
return UniResponseModel[ActivityDetailResponse](
success=detail_result.code == 0,
data=detail_result,
message="获取活动详情成功"
if detail_result.code == 0
else "获取活动详情失败",
error=None,
)
except ValidationError as ve:
conn.logger.error(f"解析活动详情响应失败: {str(ve)}")
return ProtectRouterErrorToCode().validation_error.to_json_response(
conn.logger.trace_id, "解析活动详情响应失败,请稍后重试"
)
except HTTPError as he:
conn.logger.error(f"获取活动详情异常: {str(he)}")
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
conn.logger.trace_id, "获取活动详情异常,请稍后重试"
)
except Exception as e:
conn.logger.error(f"获取活动详情未知异常: {str(e)}")
return ProtectRouterErrorToCode().unknown_error.to_json_response(
conn.logger.trace_id, "获取活动详情未知异常,请稍后重试"
)

View File

@@ -0,0 +1 @@
# 劳动俱乐部数据模型

View File

@@ -0,0 +1,22 @@
from pathlib import Path
from loveace.config.manager import config_manager
settings = config_manager.get_settings()
class LDJLBConfig:
"""劳动俱乐部模块配置常量"""
BASE_URL = "http://api-ldjlb-ac-acxk-net.vpn2.aufe.edu.cn:8118"
WEB_URL = "http://ldjlb-ac-acxk-net.vpn2.aufe.edu.cn:8118"
LOGIN_SERVICE_URL = "http://uaap-aufe-edu-cn.vpn2.aufe.edu.cn:8118/cas/login?service=http%3a%2f%2fapi.ldjlb.ac.acxk.net%2fUser%2fIndex%2fCoreLoginCallback%3fisCASGateway%3dtrue"
RSA_PRIVATE_KEY_PATH = str(
Path(settings.app.rsa_protect_key_path).joinpath("aac_private_key.pem")
)
def to_full_url(self, path: str) -> str:
"""将路径转换为完整URL"""
if path.startswith("http://") or path.startswith("https://"):
return path
return self.BASE_URL.rstrip("/") + "/" + path.lstrip("/")

View File

@@ -0,0 +1,198 @@
from typing import List, Optional
from pydantic import BaseModel, Field
class LDJLBProgressInfo(BaseModel):
"""劳动俱乐部修课进度信息"""
finish_count: int = Field(0, alias="data", description="已完成的活动数量")
@property
def progress_percentage(self) -> float:
"""计算修课进度百分比满分10次"""
return min((self.finish_count / 10.0) * 100, 100.0)
class LDJLBPageInfo(BaseModel):
"""分页信息"""
total_item_count: int = Field(0, alias="TotalItemCount", description="总条目数")
page_size: int = Field(20, alias="PageSize", description="每页大小")
current_page_index: int = Field(1, alias="CurrentPageIndex", description="当前页码")
class LDJLBActivity(BaseModel):
"""劳动俱乐部活动信息"""
id: str = Field("", alias="ID", description="活动ID")
ico: Optional[str] = Field(None, alias="Ico", description="活动图标")
state: int = Field(0, alias="State", description="活动状态代码")
state_name: str = Field("", alias="StateName", description="活动状态名称")
type_id: str = Field("", alias="TypeID", description="活动类型ID")
type_name: str = Field("", alias="TypeName", description="活动类型名称")
title: str = Field("", alias="Title", description="活动标题")
start_time: str = Field("", alias="StartTime", description="活动开始时间")
end_time: str = Field("", alias="EndTime", description="活动结束时间")
charge_user_no: str = Field("", alias="ChargeUserNo", description="负责人工号")
charge_user_name: str = Field("", alias="ChargeUserName", description="负责人姓名")
club_id: str = Field("", alias="ClubID", description="所属俱乐部ID")
club_name: str = Field("", alias="ClubName", description="所属俱乐部名称")
member_num: int = Field(0, alias="MemberNum", description="已报名人数")
add_time: str = Field("", alias="AddTime", description="活动添加时间")
people_num: int = Field(0, alias="PeopleNum", description="活动人数限制")
people_num_min: Optional[int] = Field(None, alias="PeopleNumMin", description="最小人数限制")
is_join: Optional[bool] = Field(None, alias="IsJson", description="是否已加入")
is_close: Optional[bool] = Field(None, alias="IsClose", description="是否已关闭")
sign_up_start_time: str = Field("", alias="SignUpStartTime", description="报名开始时间")
sign_up_end_time: str = Field("", alias="SignUpEndTime", description="报名结束时间")
class LDJLBActivityListResponse(BaseModel):
"""劳动俱乐部活动列表响应"""
activities: List[LDJLBActivity] = Field([], alias="data", description="活动列表")
page_info: LDJLBPageInfo = Field(..., alias="pageInfo", description="分页信息")
class LDJLBClub(BaseModel):
"""劳动俱乐部信息"""
id: str = Field("", alias="ID", description="俱乐部ID")
name: str = Field("", alias="Name", description="俱乐部名称")
type_id: str = Field("", alias="TypeID", description="俱乐部类型ID")
people_num: int = Field(0, alias="PeopleNum", description="俱乐部总人数")
project_id: str = Field("", alias="ProjectID", description="项目ID")
project_name: str = Field("", alias="PorjectName", description="项目名称")
type_name: str = Field("", alias="TypeName", description="类型名称")
ico: str = Field("", alias="Ico", description="俱乐部图标")
desc: Optional[str] = Field(None, alias="Desc", description="俱乐部描述")
chairman_no: str = Field("", alias="ChairmanNo", description="主席工号")
chairman_name: str = Field("", alias="CairmanName", description="主席姓名")
depart_code: str = Field("", alias="DepartCode", description="部门代码")
contact: Optional[str] = Field(None, alias="Contact", description="联系方式")
is_enable: bool = Field(True, alias="IsEnable", description="是否启用")
depart_name: str = Field("", alias="DpeartName", description="部门名称")
member_num: int = Field(0, alias="MemberNum", description="俱乐部成员数")
class LDJLBClubListResponse(BaseModel):
"""劳动俱乐部列表响应"""
clubs: List[LDJLBClub] = Field([], alias="data", description="俱乐部列表")
class LDJLBApplyResponse(BaseModel):
"""劳动俱乐部报名响应"""
code: int = Field(0, description="响应代码")
msg: str = Field("", description="响应消息")
class ScanSignRequest(BaseModel):
"""扫码签到请求模型"""
content: str = Field(..., description="扫码结果内容")
location: str = Field(..., description="位置信息,格式: 经度,纬度")
class ScanSignResponse(BaseModel):
"""扫码签到响应模型"""
code: int = Field(..., description="响应码,0表示成功")
msg: Optional[str] = Field(None, description="响应消息")
data: Optional[dict] = Field(None, description="响应数据")
class SignItem(BaseModel):
"""签到项信息"""
id: str = Field("", alias="ID", description="签到项ID")
type: int = Field(1, alias="Type", description="类型1=签到")
type_name: str = Field("", alias="TypeName", description="类型名称")
start_time: str = Field("", alias="StartTime", description="签到开始时间")
end_time: str = Field("", alias="EndTime", description="签到结束时间")
is_sign: bool = Field(False, alias="IsSign", description="是否已签到")
sign_time: str = Field("", alias="SignTime", description="签到时间")
class SignListResponse(BaseModel):
"""签到列表响应模型"""
code: int = Field(0, description="响应码,0表示成功")
data: List[SignItem] = Field(default_factory=list, description="签到列表数据")
class FormField(BaseModel):
"""活动表单字段"""
id: str = Field("", alias="ID", description="字段ID")
name: str = Field("", alias="Name", description="字段名称")
is_must: bool = Field(False, alias="IsMust", description="是否必填")
field_type: int = Field(1, alias="FieldType", description="字段类型")
value: str = Field("", alias="Value", description="字段值")
class FlowData(BaseModel):
"""活动审批流程数据"""
id: str = Field("", alias="ID", description="流程ID")
is_adopt: bool = Field(False, alias="IsAdopt", description="是否通过")
flow_type: int = Field(0, alias="FlowType", description="流程类型")
flow_type_name: str = Field("", alias="FlowTypeName", description="流程类型名称")
user_no: Optional[str] = Field(None, alias="UserNo", description="用户工号")
user_name: str = Field("", alias="UserName", description="用户姓名")
exam_user_no: str = Field("", alias="ExamUserNo", description="审批人工号")
exam_user_name: str = Field("", alias="ExamUserName", description="审批人姓名")
exam_comment: str = Field("", alias="ExamComment", description="审批意见")
add_time: str = Field("", alias="AddTime", description="提交时间")
exam_time: str = Field("", alias="ExamTime", description="审批时间")
class Teacher(BaseModel):
"""活动教师信息"""
user_name: str = Field("", alias="UserName", description="教师姓名")
id: str = Field("", alias="ID", description="记录ID")
activity_id: str = Field("", alias="ActivityID", description="活动ID")
user_no: str = Field("", alias="UserNo", description="教师工号")
add_time: str = Field("", alias="AddTime", description="添加时间")
add_user_no: str = Field("", alias="AddUserNo", description="添加人工号")
class ActivityDetailData(BaseModel):
"""活动详细信息数据"""
id: str = Field("", alias="ID", description="活动ID")
title: str = Field("", alias="Title", description="活动标题")
state: int = Field(0, alias="State", description="活动状态")
ico: Optional[str] = Field(None, alias="Ico", description="活动图标")
type_id: str = Field("", alias="TypeID", description="活动类型ID")
type_name: str = Field("", alias="TypeName", description="活动类型名称")
start_time: str = Field("", alias="StartTime", description="活动开始时间")
end_time: str = Field("", alias="EndTime", description="活动结束时间")
charge_user_no: str = Field("", alias="ChargeUserNo", description="负责人工号")
charge_user_name: str = Field("", alias="ChargeUserName", description="负责人姓名")
club_id: str = Field("", alias="ClubID", description="所属俱乐部ID")
club_name: str = Field("", alias="ClubName", description="所属俱乐部名称")
member_num: int = Field(0, alias="MemberNum", description="已报名人数")
add_time: str = Field("", alias="AddTime", description="活动添加时间")
apply_is_need_exam: bool = Field(False, alias="ApplyIsNeedExam", description="报名是否需要审批")
is_member: bool = Field(False, alias="IsMember", description="是否为成员")
is_manager: bool = Field(False, alias="IsManager", description="是否为管理员")
people_num: int = Field(0, alias="PeopleNum", description="活动人数限制")
people_num_min: Optional[int] = Field(None, alias="PeopleNumMin", description="最小人数限制")
is_close: Optional[bool] = Field(None, alias="IsClose", description="是否已关闭")
sign_up_start_time: str = Field("", alias="SignUpStartTime", description="报名开始时间")
sign_up_end_time: str = Field("", alias="SignUpEndTime", description="报名结束时间")
class ActivityDetailResponse(BaseModel):
"""活动详情响应模型"""
code: int = Field(0, description="响应码,0表示成功")
data: Optional[ActivityDetailData] = Field(None, description="活动详细信息")
form_data: List[FormField] = Field(default_factory=list, alias="formData", description="表单数据")
flow_data: List[FlowData] = Field(default_factory=list, alias="flowData", description="审批流程数据")
venue_reserve_data: List = Field(default_factory=list, alias="VenueReserveData", description="场地预约数据")
teacher_list: List[Teacher] = Field(default_factory=list, alias="teacherList", description="教师列表")

View File

@@ -0,0 +1 @@
# 劳动俱乐部工具函数

View File

@@ -0,0 +1,167 @@
from urllib.parse import unquote
from fastapi import Depends
from httpx import Headers
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from loveace.config.manager import config_manager
from loveace.database.creator import get_db_session
from loveace.database.ldjlb.ticket import LDJLBTicket
from loveace.router.dependencies.auth import ProtectRouterErrorToCode
from loveace.router.endpoint.ldjlb.model.base import LDJLBConfig
from loveace.service.remote.aufe import AUFEConnection
from loveace.service.remote.aufe.depends import get_aufe_conn
from loveace.utils.rsa import RSAUtils
rsa = RSAUtils.get_or_create_rsa_utils(LDJLBConfig.RSA_PRIVATE_KEY_PATH)
def _extract_and_encrypt_token(location: str, logger) -> str | None:
"""从重定向URL中提取并加密系统令牌"""
try:
sys_token = location.split("ticket=")[-1]
# URL编码转为正常字符串
sys_token = unquote(sys_token)
if not sys_token:
logger.error("系统令牌为空")
return None
logger.info(f"获取到系统令牌: {sys_token[:10]}...")
# 加密系统令牌
encrypted_token = rsa.encrypt(sys_token)
return encrypted_token
except Exception as e:
logger.error(f"解析/加密系统令牌失败: {str(e)}")
return None
async def get_system_token(conn: AUFEConnection) -> str:
next_location = LDJLBConfig.LOGIN_SERVICE_URL
max_redirects = 10 # 防止无限重定向
redirect_count = 0
try:
while redirect_count < max_redirects:
response = await conn.client.get(
next_location, follow_redirects=False, timeout=conn.timeout
)
# 如果是重定向,继续跟踪
if response.status_code in (301, 302, 303, 307, 308):
next_location = response.headers.get("Location")
if not next_location:
conn.logger.error("重定向响应中缺少 Location 头")
return ""
conn.logger.debug(f"重定向到: {next_location}")
redirect_count += 1
if "register?ticket=" in next_location:
conn.logger.info(f"重定向到劳动俱乐部注册页面: {next_location}")
encrypted_token = _extract_and_encrypt_token(
next_location, conn.logger
)
return encrypted_token if encrypted_token else ""
else:
break
if redirect_count >= max_redirects:
conn.logger.error(f"重定向次数过多 ({max_redirects})")
return ""
conn.logger.error("未能获取系统令牌")
return ""
except Exception as e:
conn.logger.error(f"获取系统令牌异常: {str(e)}")
return ""
async def get_ldjlb_header(
conn: AUFEConnection = Depends(get_aufe_conn),
db: AsyncSession = Depends(get_db_session),
) -> Headers:
"""
获取 LDJLB Ticket 的依赖项。
如果用户没有登录AUFE或UAAP或者 LDJLB Ticket 不存在且无法获取新的 Ticket则会抛出HTTP异常。
否则,返回有效的 LDJLB Ticket 字符串。
"""
# 检查 LDJLB Ticket 是否存在
async with db as session:
result = await session.execute(
select(LDJLBTicket).where(LDJLBTicket.userid == conn.userid)
)
ldjlb_ticket = result.scalars().first()
if not ldjlb_ticket:
ldjlb_ticket = await _get_or_fetch_ticket(conn, db, is_new=True)
else:
ldjlb_ticket_token = ldjlb_ticket.ldjlb_token
try:
# 解密以验证Ticket有效性
decrypted_ticket = rsa.decrypt(ldjlb_ticket_token)
if not decrypted_ticket:
raise ValueError("解密后的Ticket为空")
ldjlb_ticket = decrypted_ticket
except Exception as e:
conn.logger.error(
f"用户 {conn.userid} 的 LDJLB Ticket 无效,正在获取新的 Ticket: {str(e)}"
)
ldjlb_ticket = await _get_or_fetch_ticket(conn, db, is_new=False)
else:
conn.logger.info(f"用户 {conn.userid} 使用现有的 LDJLB Ticket")
return Headers(
{
**config_manager.get_settings().aufe.default_headers,
"ticket": ldjlb_ticket,
"sdp-app-session": conn.twf_id,
}
)
async def _get_or_fetch_ticket(
conn: AUFEConnection, db: AsyncSession, is_new: bool
) -> str:
"""获取或重新获取 LDJLB Ticket 并保存到数据库返回解密后的ticket"""
action_type = "获取" if is_new else "重新获取"
conn.logger.info(
f"用户 {conn.userid} 的 LDJLB Ticket {'不存在' if is_new else '无效'},正在{action_type}新的 Ticket"
)
encrypted_token = await get_system_token(conn)
if not encrypted_token:
conn.logger.error(f"用户 {conn.userid} {action_type} LDJLB Ticket 失败")
raise ProtectRouterErrorToCode().remote_service_error.to_http_exception(
conn.logger.trace_id,
message="获取 LDJLB Ticket 失败,请检查 AUFE/UAAP 登录状态",
)
# 解密token
try:
decrypted_token = rsa.decrypt(encrypted_token)
if not decrypted_token:
raise ValueError("解密后的Ticket为空")
except Exception as e:
conn.logger.error(f"用户 {conn.userid} 解密 LDJLB Ticket 失败: {str(e)}")
raise ProtectRouterErrorToCode().remote_service_error.to_http_exception(
conn.logger.trace_id,
message="解密 LDJLB Ticket 失败",
)
# 保存加密后的token到数据库
async with db as session:
if is_new:
session.add(LDJLBTicket(userid=conn.userid, ldjlb_token=encrypted_token))
else:
result = await session.execute(
select(LDJLBTicket).where(LDJLBTicket.userid == conn.userid)
)
existing_ticket = result.scalars().first()
if existing_ticket:
existing_ticket.ldjlb_token = encrypted_token
await session.commit()
conn.logger.success(f"用户 {conn.userid} 成功{action_type}并保存新的 LDJLB Ticket")
# 返回解密后的token
return decrypted_token

View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter
from loveace.router.endpoint.profile.flutter import profile_flutter_router
from loveace.router.endpoint.profile.model.error import ProfileErrorToCode
from loveace.router.endpoint.profile.user import profile_user_router
profile_router = APIRouter(
prefix="/profile",
responses=ProfileErrorToCode.gen_code_table(),
)
profile_router.include_router(profile_user_router)
profile_router.include_router(profile_flutter_router)

View File

@@ -0,0 +1,427 @@
from hashlib import md5
from typing import Literal
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi.responses import JSONResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from loveace.database.auth.user import ACEUser
from loveace.database.creator import get_db_session
from loveace.database.profile.flutter_profile import FlutterThemeProfile
from loveace.router.dependencies.auth import get_user_by_token
from loveace.router.dependencies.logger import logger_mixin_with_user
from loveace.router.endpoint.profile.model.error import ProfileErrorToCode
from loveace.router.endpoint.profile.model.flutter import (
FlutterImageMD5Response,
FlutterImageMode,
FlutterImageUploadResponse,
FlutterProfileResponse,
FlutterProfileUpdateRequest,
)
from loveace.router.endpoint.profile.model.uuid2s3key import Uuid2S3KeyCache
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.s3 import S3Service
from loveace.service.remote.s3.depends import get_s3_service
from loveace.utils.redis_client import RedisClient, get_redis_client
profile_flutter_router = APIRouter(
prefix="/flutter",
tags=["Flutter 资料"],
)
@profile_flutter_router.get(
"/get",
response_model=UniResponseModel[FlutterProfileResponse],
summary="获取 Flutter 用户资料",
)
async def profile_flutter_get(
session: AsyncSession = Depends(get_db_session),
user: ACEUser = Depends(get_user_by_token),
s3_service: S3Service = Depends(get_s3_service),
) -> UniResponseModel[FlutterProfileResponse] | JSONResponse:
"""
获取用户的 Flutter 应用主题配置
✅ 功能特性:
- 获取深色和浅色模式配置
- 获取背景图片、透明度、亮度设置
- 获取模糊效果参数
💡 使用场景:
- Flutter 客户端启动时加载主题
- 显示用户自定义主题设置
Returns:
FlutterProfileResponse: 包含深色/浅色模式的完整主题配置
"""
logger = logger_mixin_with_user(user.userid)
try:
result = await session.execute(
select(FlutterThemeProfile).where(
FlutterThemeProfile.user_id == user.userid
)
)
flutter_profile: FlutterThemeProfile | None = result.scalars().first()
if not flutter_profile:
return ProfileErrorToCode().profile_not_found.to_json_response(
logger.trace_id, "您还未设置用户资料,请先设置用户资料。"
)
if flutter_profile.light_mode_background_url:
if light_bg_url := await s3_service.generate_presigned_url_from_direct_url(
flutter_profile.light_mode_background_url
):
flutter_profile.light_mode_background_url = light_bg_url
else:
logger.warning("生成用户浅色模式背景预签名 URL 失败,可能图片已被删除")
flutter_profile.light_mode_background_url = ""
else:
flutter_profile.light_mode_background_url = ""
if flutter_profile.dark_mode_background_url:
if dark_bg_url := await s3_service.generate_presigned_url_from_direct_url(
flutter_profile.dark_mode_background_url
):
flutter_profile.dark_mode_background_url = dark_bg_url
else:
logger.warning("生成用户深色模式背景预签名 URL 失败,可能图片已被删除")
flutter_profile.dark_mode_background_url = ""
else:
flutter_profile.dark_mode_background_url = ""
flutter_response = FlutterProfileResponse(
dark_mode=flutter_profile.dark_mode,
light_mode_opacity=flutter_profile.light_mode_opacity,
light_mode_brightness=flutter_profile.light_mode_brightness,
light_mode_background_url=flutter_profile.light_mode_background_url,
light_mode_blur=flutter_profile.light_mode_blur,
dark_mode_opacity=flutter_profile.dark_mode_opacity,
dark_mode_brightness=flutter_profile.dark_mode_brightness,
dark_mode_background_url=flutter_profile.dark_mode_background_url,
dark_mode_background_blur=flutter_profile.dark_mode_background_blur,
)
return UniResponseModel[FlutterProfileResponse](
success=True,
data=flutter_response,
message="获取 Flutter 用户资料成功",
error=None,
)
except Exception as e:
logger.error("获取 Flutter 用户资料时发生错误")
logger.exception(e)
return ProfileErrorToCode().server_error.to_json_response(logger.trace_id)
@profile_flutter_router.put(
"/image/upload",
response_model=UniResponseModel[FlutterImageUploadResponse],
summary="上传 Flutter 背景图片",
)
async def profile_flutter_image_upload(
background_image_upload: UploadFile = File(
..., description="背景图片文件,限制大小小于 5MB"
),
user: ACEUser = Depends(get_user_by_token),
s3_service: S3Service = Depends(get_s3_service),
redis_client: RedisClient = Depends(get_redis_client),
) -> UniResponseModel[FlutterImageUploadResponse] | JSONResponse:
"""
上传 Flutter 主题的背景图片
✅ 功能特性:
- 支持 JPEG 和 PNG 格式
- 限制文件大小为 5MB 以内
- 上传后返回临时 UUID需要通过 /update 接口确认才会保存
⚠️ 限制条件:
- 仅支持 JPEG 和 PNG 格式
- 文件大小不能超过 5MB
- 上传的临时文件有效期为 1 小时
💡 使用场景:
- 用户上传深色模式背景图片
- 用户上传浅色模式背景图片
Args:
background_image_upload: 背景图片文件
Returns:
FlutterImageUploadResponse: 包含临时图片 UUID
"""
logger = logger_mixin_with_user(user.userid)
print(background_image_upload.content_type)
if not background_image_upload.content_type and not background_image_upload.size:
logger.warning("上传的背景图片文件缺少必要的内容类型或大小信息")
return ProfileErrorToCode().mimetype_not_allowed.to_json_response(
logger.trace_id, "上传的背景图片文件缺少必要的内容类型或大小信息"
)
if background_image_upload.size and background_image_upload.size > 5 * 1024 * 1024:
logger.warning("上传的背景图片文件过大")
return ProfileErrorToCode().too_large_image.to_json_response(
logger.trace_id, "上传的背景图片文件过大最大允许5MB"
)
if background_image_upload.content_type not in ["image/jpeg", "image/png"]:
logger.warning("上传的背景图片文件格式不支持")
return ProfileErrorToCode().mimetype_not_allowed.to_json_response(
logger.trace_id, "上传的背景图片文件格式不支持,仅支持 JPEG、PNG"
)
md5_hash = md5(background_image_upload.file.read()).hexdigest()
background_image_upload.file.seek(0)
s3_key = f"backgrounds/{user.userid}/never_use/{user.userid}-{md5_hash}.jpg"
back_upload = await s3_service.upload_obj(
file_obj=background_image_upload.file,
s3_key=s3_key,
extra_args={"ContentType": background_image_upload.content_type},
)
if not back_upload.success or not back_upload.url:
logger.error("上传用户背景图片到 S3 失败")
return ProfileErrorToCode().remote_service_error.to_json_response(
logger.trace_id, "上传用户背景图片失败,请稍后重试"
)
cache_data = Uuid2S3KeyCache(s3_key=s3_key, md5=md5_hash)
await redis_client.set_object(
key=f"flutter:background:{user.userid}-{md5_hash}",
value=cache_data,
model_class=Uuid2S3KeyCache,
expire=3600,
)
upload_response = FlutterImageUploadResponse(uuid=f"{user.userid}-{md5_hash}", md5=md5_hash)
return UniResponseModel[FlutterImageUploadResponse](
success=True,
data=upload_response,
message="上传背景图片成功",
error=None,
)
@profile_flutter_router.put(
"/update",
response_model=UniResponseModel[FlutterProfileResponse],
summary="更新 Flutter 用户资料",
)
async def profile_flutter_update(
profile_update_request: FlutterProfileUpdateRequest,
session: AsyncSession = Depends(get_db_session),
user: ACEUser = Depends(get_user_by_token),
s3_service: S3Service = Depends(get_s3_service),
redis_client: RedisClient = Depends(get_redis_client),
) -> UniResponseModel[FlutterProfileResponse] | JSONResponse:
"""
更新用户的 Flutter 主题配置
✅ 功能特性:
- 支持更新深色和浅色模式配置
- 支持更新背景图片、透明度、亮度、模糊效果
- 可同时更新或选择性更新
💡 使用场景:
- 用户自定义 Flutter 客户端主题
- 修改深色模式或浅色模式设置
- 更新背景图片
Args:
profile_update_request: 包含要更新的主题配置字段
session: 数据库会话
user: 当前用户
s3_service: S3 服务
redis_client: Redis 客户端
Returns:
FlutterProfileResponse: 更新后的主题配置
"""
logger = logger_mixin_with_user(user.userid)
try:
if not any(
[
profile_update_request.dark_mode,
profile_update_request.light_mode_opacity,
profile_update_request.light_mode_brightness,
profile_update_request.light_mode_background_uuid,
profile_update_request.light_mode_blur,
profile_update_request.dark_mode_opacity,
profile_update_request.dark_mode_brightness,
profile_update_request.dark_mode_background_uuid,
profile_update_request.dark_mode_background_blur,
]
):
logger.warning("未提供任何更新的资料字段")
return ProfileErrorToCode().need_one_more_field.to_json_response(
logger.trace_id, "未提供任何更新的资料字段"
)
result = await session.execute(
select(FlutterThemeProfile).where(
FlutterThemeProfile.user_id == user.userid
)
)
flutter_profile: FlutterThemeProfile | None = result.scalars().first()
if not flutter_profile:
flutter_profile = FlutterThemeProfile(user_id=user.userid)
if profile_update_request.dark_mode is not None:
flutter_profile.dark_mode = profile_update_request.dark_mode
if profile_update_request.light_mode_opacity is not None:
flutter_profile.light_mode_opacity = (
profile_update_request.light_mode_opacity
)
if profile_update_request.light_mode_brightness is not None:
flutter_profile.light_mode_brightness = (
profile_update_request.light_mode_brightness
)
if profile_update_request.light_mode_background_uuid is not None:
light_bg_cache = await redis_client.get_object(
key=f"flutter:background:{profile_update_request.light_mode_background_uuid}",
model_class=Uuid2S3KeyCache,
)
if light_bg_cache:
copy = await s3_service.copy_object(
source_key=light_bg_cache.s3_key,
dest_key=f"backgrounds/{user.userid}/{user.userid}-light.jpg",
)
if copy.success and copy.dest_url:
flutter_profile.light_mode_background_url = copy.dest_url
flutter_profile.light_mode_background_md5 = (
light_bg_cache.md5 if light_bg_cache else ""
)
await redis_client.delete(
key=f"flutter:background:{profile_update_request.light_mode_background_uuid}"
)
else:
logger.warning("提供的浅色模式背景图片 UUID 无效或已过期")
return ProfileErrorToCode().resource_expired.to_json_response(
logger.trace_id, "提供的浅色模式背景图片 UUID 无效或已过期"
)
if profile_update_request.light_mode_blur is not None:
flutter_profile.light_mode_blur = profile_update_request.light_mode_blur
if profile_update_request.dark_mode_opacity is not None:
flutter_profile.dark_mode_opacity = profile_update_request.dark_mode_opacity
if profile_update_request.dark_mode_brightness is not None:
flutter_profile.dark_mode_brightness = (
profile_update_request.dark_mode_brightness
)
if profile_update_request.dark_mode_background_uuid is not None:
dark_bg_cache = await redis_client.get_object(
key=f"flutter:background:{profile_update_request.dark_mode_background_uuid}",
model_class=Uuid2S3KeyCache,
)
if dark_bg_cache:
copy = await s3_service.copy_object(
source_key=dark_bg_cache.s3_key,
dest_key=f"backgrounds/{user.userid}/{user.userid}-dark.jpg",
)
if copy.success and copy.dest_url:
flutter_profile.dark_mode_background_url = copy.dest_url
flutter_profile.dark_mode_background_md5 = (
dark_bg_cache.md5 if dark_bg_cache else ""
)
await redis_client.delete(
key=f"flutter:background:{profile_update_request.dark_mode_background_uuid}"
)
else:
logger.warning("提供的深色模式背景图片 UUID 无效或已过期")
return ProfileErrorToCode().resource_expired.to_json_response(
logger.trace_id, "提供的深色模式背景图片 UUID 无效或已过期"
)
if profile_update_request.dark_mode_background_blur is not None:
flutter_profile.dark_mode_background_blur = (
profile_update_request.dark_mode_background_blur
)
session.add(flutter_profile)
await session.commit()
flutter_response = FlutterProfileResponse(
dark_mode=flutter_profile.dark_mode,
light_mode_opacity=flutter_profile.light_mode_opacity,
light_mode_brightness=flutter_profile.light_mode_brightness,
light_mode_background_url=(
await s3_service.generate_presigned_url_from_direct_url(
flutter_profile.light_mode_background_url
)
if flutter_profile.light_mode_background_url
else ""
),
light_mode_blur=flutter_profile.light_mode_blur,
dark_mode_opacity=flutter_profile.dark_mode_opacity,
dark_mode_brightness=flutter_profile.dark_mode_brightness,
dark_mode_background_url=(
await s3_service.generate_presigned_url_from_direct_url(
flutter_profile.dark_mode_background_url
)
if flutter_profile.dark_mode_background_url
else ""
),
dark_mode_background_blur=flutter_profile.dark_mode_background_blur,
)
return UniResponseModel[FlutterProfileResponse](
success=True,
data=flutter_response,
message="更新 Flutter 用户资料成功",
error=None,
)
except Exception as e:
logger.error("更新 Flutter 用户资料时发生错误")
logger.exception(e)
return ProfileErrorToCode().server_error.to_json_response(logger.trace_id)
@profile_flutter_router.get(
"/image/{mode}/md5",
summary="获取 Flutter 背景图片的 MD5 值",
response_model=UniResponseModel[FlutterImageMD5Response],
)
async def profile_flutter_image_md5(
mode: FlutterImageMode,
user: ACEUser = Depends(get_user_by_token),
session: AsyncSession = Depends(get_db_session),
) -> UniResponseModel[FlutterImageMD5Response] | JSONResponse:
"""
获取 Flutter 主题背景图片的 MD5 值
✅ 功能特性:
- 支持获取深色模式或浅色模式背景图片的 MD5 值
- 通过用户 ID 定位对应的背景图片
💡 使用场景:
- 验证当前背景图片是否被篡改
- 用于缓存或同步背景图片时的完整性校验
Args:
black_or_white: 指定获取深色模式black或浅色模式white的背景图片 MD5 值
user: 当前用户
redis_client: Redis 客户端
Returns:
FlutterImageMD5Response: 包含背景图片的 MD5 值
"""
logger = logger_mixin_with_user(user.userid)
try:
if mode == FlutterImageMode.DARK:
md5_value = await session.execute(
select(FlutterThemeProfile.dark_mode_background_md5).where(
FlutterThemeProfile.user_id == user.userid
)
)
result_md5 = md5_value.scalars().first()
else:
md5_value = await session.execute(
select(FlutterThemeProfile.light_mode_background_md5).where(
FlutterThemeProfile.user_id == user.userid
)
)
result_md5 = md5_value.scalars().first()
if result_md5:
result = FlutterImageMD5Response(md5=result_md5)
return UniResponseModel[FlutterImageMD5Response](
success=True,
data=result,
message="获取 Flutter 背景图片 MD5 值成功",
error=None,
)
else:
logger.warning("用户背景图片的 MD5 值未找到")
return ProfileErrorToCode().profile_not_found.to_json_response(
logger.trace_id, "用户背景图片的 MD5 值未找到"
)
except Exception as e:
logger.error("获取 Flutter 背景图片 MD5 值时发生错误")
logger.exception(e)
return ProfileErrorToCode().server_error.to_json_response(logger.trace_id)

View File

@@ -0,0 +1,46 @@
from fastapi import status
from loveace.router.schemas import ErrorToCode, ErrorToCodeNode
class ProfileErrorToCode(ErrorToCode):
profile_not_found: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_404_NOT_FOUND,
code="PROFILE_NOT_FOUND",
message="用户资料未找到",
)
unauthorized_access: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_401_UNAUTHORIZED,
code="UNAUTHORIZED_ACCESS",
message="未授权的访问",
)
need_one_more_field: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_400_BAD_REQUEST,
code="NEED_ONE_MORE_FIELD",
message="需要至少提供一个字段进行更新",
)
too_large_image: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
code="TOO_LARGE_IMAGE",
message="上传的图片过大",
)
mimetype_not_allowed: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
code="MIMETYPE_NOT_ALLOWED",
message="不支持的图片格式",
)
resource_expired: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_410_GONE,
code="RESOURCE_EXPIRED",
message="资源已过期",
)
remote_service_error: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_502_BAD_GATEWAY,
code="REMOTE_SERVICE_ERROR",
message="远程服务错误",
)
server_error: ErrorToCodeNode = ErrorToCodeNode(
error_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
code="SERVER_ERROR",
message="服务器错误",
)

View File

@@ -0,0 +1,56 @@
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
class FlutterImageUploadResponse(BaseModel):
uuid: str = Field(..., description="图片的UUID")
md5: str = Field(..., description="图片的MD5值")
class FlutterProfileResponse(BaseModel):
dark_mode: bool = Field(..., description="是否启用暗黑模式")
light_mode_opacity: float = Field(..., description="浅色模式下的透明度")
light_mode_brightness: float = Field(..., description="浅色模式下的亮度")
light_mode_background_url: Optional[str] = Field(
None, description="浅色模式下的背景图片 URL"
)
light_mode_blur: float = Field(..., description="浅色模式下的背景模糊程度")
dark_mode_opacity: float = Field(..., description="深色模式下的透明度")
dark_mode_brightness: float = Field(..., description="深色模式下的亮度")
dark_mode_background_url: Optional[str] = Field(
None, description="深色模式下的背景图片 URL"
)
dark_mode_background_blur: float = Field(
..., description="深色模式下的背景模糊程度"
)
class FlutterProfileUpdateRequest(BaseModel):
dark_mode: Optional[bool] = Field(None, description="是否启用暗黑模式")
light_mode_opacity: Optional[float] = Field(None, description="浅色模式下的透明度")
light_mode_brightness: Optional[float] = Field(None, description="浅色模式下的亮度")
light_mode_background_uuid: Optional[str] = Field(
None, description="浅色模式下的背景图片 UUID"
)
light_mode_blur: Optional[float] = Field(
None, description="浅色模式下的背景模糊程度"
)
dark_mode_opacity: Optional[float] = Field(None, description="深色模式下的透明度")
dark_mode_brightness: Optional[float] = Field(None, description="深色模式下的亮度")
dark_mode_background_uuid: Optional[str] = Field(
None, description="深色模式下的背景图片 UUID"
)
dark_mode_background_blur: Optional[float] = Field(
None, description="深色模式下的背景模糊程度"
)
class FlutterImageMD5Response(BaseModel):
md5: str = Field(..., description="图片的MD5值")
class FlutterImageMode(Enum):
LIGHT = "light"
DARK = "dark"

View File

@@ -0,0 +1,24 @@
from typing import Optional
from pydantic import BaseModel, Field
class UserProfileUpdateRequest(BaseModel):
nickname: Optional[str] = Field(..., description="用户昵称")
slogan: Optional[str] = Field(..., description="用户个性签名")
avatar_uuid: Optional[str] = Field(..., description="用户头像UUID")
class UserProfileResponse(BaseModel):
nickname: str = Field(..., description="用户昵称")
slogan: str = Field(..., description="用户个性签名")
avatar_url: str = Field(..., description="用户头像URL")
class AvatarUpdateResponse(BaseModel):
uuid: str = Field(..., description="新的头像UUID")
md5: str = Field(..., description="头像文件的MD5值")
class AvatarMD5Response(BaseModel):
md5: str = Field(..., description="用户头像的MD5值")

View File

@@ -0,0 +1,8 @@
from pydantic import BaseModel, Field
class Uuid2S3KeyCache(BaseModel):
"""UUID 到 S3 Key 的缓存模型"""
s3_key: str = Field(..., description="S3对象的key")
md5: str = Field(..., description="文件的MD5值")

View File

@@ -0,0 +1,339 @@
from hashlib import md5
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi.responses import JSONResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from loveace.database.auth.user import ACEUser
from loveace.database.creator import get_db_session
from loveace.database.profile.user_profile import UserProfile
from loveace.router.dependencies.auth import get_user_by_token
from loveace.router.dependencies.logger import logger_mixin_with_user
from loveace.router.endpoint.profile.model.error import ProfileErrorToCode
from loveace.router.endpoint.profile.model.user import (
AvatarMD5Response,
AvatarUpdateResponse,
UserProfileResponse,
UserProfileUpdateRequest,
)
from loveace.router.endpoint.profile.model.uuid2s3key import Uuid2S3KeyCache
from loveace.router.schemas.uniresponse import UniResponseModel
from loveace.service.remote.s3 import S3Service
from loveace.service.remote.s3.depends import get_s3_service
from loveace.utils.redis_client import RedisClient, get_redis_client
profile_user_router = APIRouter(
prefix="/user",
tags=["用户资料"],
)
@profile_user_router.get(
"/get", response_model=UniResponseModel[UserProfileResponse], summary="获取用户资料"
)
async def profile_user_get(
session: AsyncSession = Depends(get_db_session),
user: ACEUser = Depends(get_user_by_token),
s3_service: S3Service = Depends(get_s3_service),
) -> UniResponseModel[UserProfileResponse] | JSONResponse:
"""
获取当前用户的资料信息
✅ 功能特性:
- 获取昵称、个签、头像等用户资料
- 实时从数据库查询最新信息
💡 使用场景:
- 个人中心展示用户资料
- 编辑资料前获取当前信息
- 其他用户查看个人资料
Returns:
UserProfileResponse: 包含昵称、个签、头像 URL
"""
logger = logger_mixin_with_user(user.userid)
try:
result = await session.execute(
select(UserProfile).where(UserProfile.user_id == user.userid)
)
user_profile: UserProfile | None = result.scalars().first()
if not user_profile:
return ProfileErrorToCode().profile_not_found.to_json_response(
logger.trace_id, "您还未设置用户资料,请先设置用户资料。"
)
if user_profile.avatar_url:
if avatar_url := await s3_service.generate_presigned_url_from_direct_url(
user_profile.avatar_url
):
avatar_url = avatar_url
else:
logger.warning("生成用户头像预签名 URL 失败,可能头像已被删除")
avatar_url = ""
else:
avatar_url = ""
user_response = UserProfileResponse(
nickname=user_profile.nickname,
slogan=user_profile.slogan,
avatar_url=avatar_url,
)
return UniResponseModel[UserProfileResponse](
success=True,
data=user_response,
message="获取用户资料成功",
error=None,
)
except Exception as e:
logger.error("获取用户资料时发生错误")
logger.exception(e)
return ProfileErrorToCode().server_error.to_json_response(logger.trace_id)
@profile_user_router.put(
"/avatar/upload",
response_model=UniResponseModel[AvatarUpdateResponse],
summary="上传用户头像",
)
async def profile_user_avatar_upload(
avatar_update_request: UploadFile = File(
..., description="用户头像文件,限制大小小于 5MB"
),
user: ACEUser = Depends(get_user_by_token),
s3_service: S3Service = Depends(get_s3_service),
redis_client: RedisClient = Depends(get_redis_client),
) -> UniResponseModel[AvatarUpdateResponse] | JSONResponse:
"""
上传用户头像到 S3 存储
✅ 功能特性:
- 支持 JPEG 和 PNG 格式
- 限制文件大小为 5MB 以内
- 上传后返回临时 UUID需要通过 /update 接口确认才会保存
⚠️ 限制条件:
- 仅支持 JPEG 和 PNG 格式
- 文件大小不能超过 5MB
- 上传的临时文件有效期为 1 小时
💡 使用场景:
- 用户上传新头像
- 裁剪或预览后再确认保存
Args:
avatar_update_request: 头像文件
Returns:
AvatarUpdateResponse: 包含临时头像 UUID后续需在 /update 接口中使用
"""
logger = logger_mixin_with_user(user.userid)
if not avatar_update_request.content_type and not avatar_update_request.size:
logger.warning("上传的头像文件缺少必要的内容类型或大小信息")
return ProfileErrorToCode().mimetype_not_allowed.to_json_response(
logger.trace_id, "上传的头像文件缺少必要的内容类型或大小信息"
)
if avatar_update_request.size and avatar_update_request.size > 5 * 1024 * 1024:
logger.warning("上传的头像文件过大")
return ProfileErrorToCode().too_large_image.to_json_response(
logger.trace_id, "上传的头像文件过大最大允许5MB"
)
if avatar_update_request.content_type not in ["image/jpeg", "image/png"]:
logger.warning("上传的头像文件格式不支持")
return ProfileErrorToCode().mimetype_not_allowed.to_json_response(
logger.trace_id, "上传的头像文件格式不支持,仅支持 JPEG、PNG"
)
s3_key = f"avatars/{user.userid}/never_use/{user.userid}.jpg"
avatar_upload = await s3_service.upload_obj(
file_obj=avatar_update_request.file,
s3_key=s3_key,
extra_args={"ContentType": avatar_update_request.content_type},
)
if not avatar_upload.success or not avatar_upload.url:
logger.error("上传用户头像到 S3 失败")
return ProfileErrorToCode().remote_service_error.to_json_response(
logger.trace_id, "上传用户头像失败,请稍后重试"
)
avatar_update_request.file.seek(0)
md5_hash = md5(avatar_update_request.file.read()).hexdigest()
logger.info(f"计算上传头像的 MD5 值: {md5_hash}")
cache_data = Uuid2S3KeyCache(s3_key=s3_key, md5=md5_hash)
await redis_client.set_object(
key=f"user:avatar:{user.userid}",
value=cache_data,
model_class=Uuid2S3KeyCache,
expire=3600,
)
avatar_response = AvatarUpdateResponse(uuid=user.userid, md5=md5_hash)
return UniResponseModel[AvatarUpdateResponse](
success=True,
data=avatar_response,
message="上传头像成功",
error=None,
)
@profile_user_router.put(
"/update",
response_model=UniResponseModel[UserProfileResponse],
summary="更新用户资料",
)
async def profile_user_update(
profile_update_request: UserProfileUpdateRequest,
session: AsyncSession = Depends(get_db_session),
user: ACEUser = Depends(get_user_by_token),
s3_service: S3Service = Depends(get_s3_service),
redis_client: RedisClient = Depends(get_redis_client),
) -> UniResponseModel[UserProfileResponse] | JSONResponse:
"""
更新用户资料(昵称、个签、头像)
✅ 功能特性:
- 支持更新昵称、个签、头像
- 可同时更新或选择性更新
- 头像通过 /avatar/upload 上传后,需传入 avatar_uuid 进行确认
💡 使用场景:
- 用户编辑个人资料
- 修改昵称或个签
- 确认并保存头像
Args:
profile_update_request: 包含要更新的资料字段(至少一个)
session: 数据库会话
user: 当前用户
s3_service: S3 服务
redis_client: Redis 客户端
Returns:
UserProfileResponse: 更新后的用户资料
"""
logger = logger_mixin_with_user(user.userid)
try:
if not any(
[
profile_update_request.nickname,
profile_update_request.slogan,
profile_update_request.avatar_uuid,
]
):
logger.warning("用户资料更新请求中未包含任何可更新的字段")
return ProfileErrorToCode().need_one_more_field.to_json_response(
logger.trace_id, "请至少提供一个需要更新的字段"
)
result = await session.execute(
select(UserProfile).where(UserProfile.user_id == user.userid)
)
user_profile: UserProfile | None = result.scalars().first()
avatar_url = ""
preset_avatar_cache = None
if profile_update_request.avatar_uuid:
preset_avatar_cache = await redis_client.get_object(
key=f"user:avatar:{profile_update_request.avatar_uuid}",
model_class=Uuid2S3KeyCache,
)
if preset_avatar_cache:
copy = await s3_service.copy_object(
source_key=preset_avatar_cache.s3_key,
dest_key=f"avatars/{user.userid}/{user.userid}.jpg",
)
if copy.success:
avatar_url = copy.dest_url
else:
logger.error("复制用户头像到正式存储位置失败")
return ProfileErrorToCode().remote_service_error.to_json_response(
logger.trace_id, "设置用户头像失败,请稍后重试"
)
if not user_profile:
user_profile = UserProfile(
user_id=user.userid,
nickname=profile_update_request.nickname,
slogan=profile_update_request.slogan,
avatar_url=avatar_url if preset_avatar_cache else "",
avatar_md5=preset_avatar_cache.md5 if preset_avatar_cache else "",
)
session.add(user_profile)
else:
if profile_update_request.nickname:
user_profile.nickname = profile_update_request.nickname
if profile_update_request.slogan:
user_profile.slogan = profile_update_request.slogan
if profile_update_request.avatar_uuid:
if avatar_url:
user_profile.avatar_url = avatar_url
user_profile.avatar_md5 = (
preset_avatar_cache.md5 if preset_avatar_cache else ""
)
await redis_client.delete(
key=f"user:avatar:{profile_update_request.avatar_uuid}"
)
else:
logger.warning("提供的头像 UUID 无效或已过期")
return ProfileErrorToCode().resource_expired.to_json_response(
logger.trace_id, "提供的头像 UUID 无效或已过期"
)
await session.commit()
if user_profile.avatar_url:
avatar_url = await s3_service.generate_presigned_url_from_direct_url(
user_profile.avatar_url
)
if not avatar_url:
logger.warning("生成用户头像预签名 URL 失败,可能头像已被删除")
avatar_url = ""
else:
avatar_url = ""
user_response = UserProfileResponse(
nickname=user_profile.nickname,
slogan=user_profile.slogan if user_profile.slogan else "",
avatar_url=avatar_url,
)
return UniResponseModel[UserProfileResponse](
success=True,
data=user_response,
message="更新用户资料成功",
error=None,
)
except Exception as e:
logger.error("更新用户资料时发生错误")
logger.exception(e)
return ProfileErrorToCode().server_error.to_json_response(logger.trace_id)
@profile_user_router.get(
"/avatar/md5",
summary="获取用户头像的MD5值",
response_model=UniResponseModel[AvatarMD5Response],
)
async def profile_user_avatar_md5(
session: AsyncSession = Depends(get_db_session),
user: ACEUser = Depends(get_user_by_token),
) -> UniResponseModel[AvatarMD5Response] | JSONResponse:
"""
获取当前用户头像的 MD5 值
✅ 功能特性:
- 从数据库中获取用户头像的 MD5 值
- 用于验证头像文件完整性或进行缓存控制
💡 使用场景:
- 在头像上传后,验证文件的完整性
- 缓存头像文件的 MD5 值,以便后续快速验证
"""
logger = logger_mixin_with_user(user.userid)
result = await session.execute(
select(UserProfile.avatar_md5).where(UserProfile.user_id == user.userid)
)
avatar_md5: str | None = result.scalar()
if not avatar_md5:
logger.warning("用户头像的 MD5 值未找到")
return ProfileErrorToCode().profile_not_found.to_json_response(
logger.trace_id, "用户头像的 MD5 值未找到"
)
return UniResponseModel[AvatarMD5Response](
success=True,
data=AvatarMD5Response(md5=avatar_md5),
message="获取用户头像的 MD5 值成功",
error=None,
)

View File

@@ -0,0 +1,35 @@
from fastapi import APIRouter
from pydantic import BaseModel, Field
class AliveResponse(BaseModel):
message: str = Field(
default="LoveACE is alive and running!", description="服务状态消息"
)
alive_router = APIRouter()
@alive_router.get(
"/alive",
response_model=AliveResponse,
tags=["服务健康检查"],
summary="服务健康检查接口",
)
async def alive_check():
"""
服务健康检查接口
✅ 功能特性:
- 返回服务运行状态
- 提供简单的健康检查响应
💡 使用场景:
- 监控服务状态
- 负载均衡器健康检查
Returns:
AliveResponse: 包含服务状态消息的响应模型
"""
return AliveResponse()

View File

@@ -0,0 +1,15 @@
"""Router schemas module"""
from loveace.router.schemas.base import (
ErrorModel,
ErrorToCode,
ErrorToCodeNode,
)
from loveace.router.schemas.error import ProtectRouterErrorToCode
__all__ = [
"ErrorModel",
"ErrorToCodeNode",
"ErrorToCode",
"ProtectRouterErrorToCode",
]

View File

@@ -0,0 +1,178 @@
"""
定义请求和响应的基础模型,以及错误处理模型
"""
from typing import Annotated, Any, Dict
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from loveace.router.schemas.exception import UniResponseHTTPException
from loveace.router.schemas.model import (
ErrorModel,
ValidationErrorDetail,
ValidationErrorModel,
)
from loveace.router.schemas.uniresponse import UniResponseModel
class ErrorToCodeNode(BaseModel):
message: str = Field(..., description="错误信息")
error_code: int = Field(..., description="错误代码")
code: str = Field(..., description="错误短ID")
def to_http_exception(
self, trace_id: str, message: str | None = None
) -> UniResponseHTTPException:
"""
将错误信息转换为HTTPException次方法使用于 依赖注入 | 中间件 | 抛出异常 的情况,请 raise 此异常。
"""
return UniResponseHTTPException(
status_code=self.error_code,
uni_response=UniResponseModel(
success=False,
data=None,
error=ErrorModel(
message=message if message else self.message,
code=self.code,
trace_id=trace_id,
),
message=None,
),
)
def to_json_response(
self, trace_id: str, message: str | None = None
) -> JSONResponse:
"""
将错误信息转换为JSONResponse适用于一个标准 Router 的返回。
"""
return JSONResponse(
status_code=self.error_code,
content=UniResponseModel(
success=False,
data=None,
error=ErrorModel(
message=message if message else self.message,
code=self.code,
trace_id=trace_id,
),
message=None,
).model_dump(),
)
class ErrorToCode(BaseModel):
VALIDATION_ERROR: ErrorToCodeNode = ErrorToCodeNode(
message="请求参数验证错误",
error_code=422,
code="VALIDATION_ERROR",
)
@classmethod
def gen_code_table(cls) -> Dict[str | int, Dict[str, Any]]:
"""
生成FastAPI兼容的响应文档格式
支持同一状态码下的多个模型示例
对 422 状态码进行特殊处理,使用 ValidationErrorDetail
"""
data = cls().model_dump()
status_info = {}
# 按状态码分组错误信息
for k, v in data.items():
status_code = str(v["error_code"])
if status_code not in status_info:
status_info[status_code] = {"descriptions": [], "examples": []}
# 添加描述
status_info[status_code]["descriptions"].append(v["message"])
# 对 422 状态码进行特殊处理
if v["error_code"] == 422:
# 使用 ValidationErrorDetail 作为示例
example_detail = ValidationErrorModel(
message="请求参数验证失败",
code=v["code"],
trace_id="",
details=[
ValidationErrorDetail(
loc=["body", "field_name"],
msg="field required",
type="value_error.missing",
)
],
)
else:
# 其他状态码使用 ErrorModel
example_detail = ErrorModel(
message=v["message"],
code=v["code"],
trace_id="",
)
status_info[status_code]["examples"].append(
{
"summary": f"{v['code']} 错误",
"description": v["message"],
"value": UniResponseModel(
success=False,
message=None,
data=None,
error=example_detail,
).model_dump(),
}
)
# 构建FastAPI响应格式
responses = {}
for status_code, info in status_info.items():
descriptions = info["descriptions"]
examples = info["examples"]
# 合并描述
if len(descriptions) == 1:
combined_description = descriptions[0]
else:
combined_description = "; ".join(descriptions)
# 对 422 状态码进行特殊处理
if status_code == "422":
# 为 422 创建专门的响应模型
response_def = {
"model": Annotated[
UniResponseModel,
Field(
description=combined_description,
examples=[example["value"] for example in examples],
),
],
"description": combined_description,
}
else:
# 其他状态码使用通用模型
response_def = {
"model": UniResponseModel,
"description": combined_description,
}
# 如果有示例添加content字段
if examples:
# 创建examples字典
examples_dict = {}
for i, example in enumerate(examples):
key = f"example_{i + 1}_{example['summary'].lower().replace(' ', '_').replace('错误', 'error')}"
examples_dict[key] = {
"summary": example["summary"],
"description": example["description"],
"value": example["value"],
}
response_def["content"] = {
"application/json": {"examples": examples_dict}
}
responses[status_code] = response_def
return responses

View File

@@ -0,0 +1,65 @@
"""
经过一层封装的错误代码映射,专用于保护路由
"""
from fastapi import status
from loveace.router.schemas.base import ErrorToCode, ErrorToCodeNode
class ProtectRouterErrorToCode(ErrorToCode):
invalid_authentication: ErrorToCodeNode = ErrorToCodeNode(
message="无效的认证信息",
error_code=status.HTTP_401_UNAUTHORIZED,
code="INVALID_AUTHENTICATION",
)
forbidden: ErrorToCodeNode = ErrorToCodeNode(
message="禁止访问",
error_code=status.HTTP_403_FORBIDDEN,
code="FORBIDDEN",
)
cooldown: ErrorToCodeNode = ErrorToCodeNode(
message="请求过于频繁,请稍后再试",
error_code=status.HTTP_429_TOO_MANY_REQUESTS,
code="COOLDOWN",
)
user_need_reset_password: ErrorToCodeNode = ErrorToCodeNode(
message="用户需要重置密码",
error_code=status.HTTP_403_FORBIDDEN,
code="USER_NEED_RESET_PASSWORD",
)
remote_service_error: ErrorToCodeNode = ErrorToCodeNode(
message="远程服务错误",
error_code=status.HTTP_502_BAD_GATEWAY,
code="REMOTE_SERVICE_ERROR",
)
validation_error: ErrorToCodeNode = ErrorToCodeNode(
message="数据验证失败",
error_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
code="VALIDATION_ERROR",
)
server_error: ErrorToCodeNode = ErrorToCodeNode(
message="服务器错误",
error_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
code="SERVER_ERROR",
)
null_response: ErrorToCodeNode = ErrorToCodeNode(
message="远程服务返回空响应",
error_code=status.HTTP_502_BAD_GATEWAY,
code="NULL_RESPONSE",
)
timeout: ErrorToCodeNode = ErrorToCodeNode(
message="请求远程服务超时",
error_code=status.HTTP_504_GATEWAY_TIMEOUT,
code="TIMEOUT",
)
unknown_error: ErrorToCodeNode = ErrorToCodeNode(
message="未知错误",
error_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
code="UNKNOWN",
)
empty_path: ErrorToCodeNode = ErrorToCodeNode(
message="请求路径不能为空",
error_code=status.HTTP_400_BAD_REQUEST,
code="EMPTY_PATH",
)

View File

@@ -0,0 +1,11 @@
from loveace.router.schemas.uniresponse import UniResponseModel
class UniResponseHTTPException(Exception):
"""
统一响应格式的 HTTP 异常,用于在路由中直接抛出异常时使用。
"""
def __init__(self, status_code: int, uni_response: UniResponseModel):
self.status_code = status_code
self.uni_response = uni_response

View File

@@ -0,0 +1,19 @@
from typing import Any, List
from pydantic import BaseModel, Field
class ErrorModel(BaseModel):
message: str = Field(..., description="详细信息")
code: str = Field(..., description="错误短ID")
trace_id: str = Field(..., description="trace_id")
class ValidationErrorDetail(BaseModel):
loc: List[Any] = Field(..., description="错误位置")
msg: str = Field(..., description="错误信息")
type: str = Field(..., description="错误类型")
class ValidationErrorModel(ErrorModel):
details: List[ValidationErrorDetail] = Field(..., description="验证错误详情")

View File

@@ -0,0 +1,45 @@
import time
from typing import Generic, TypeVar, Union
from pydantic import BaseModel, Field
from loveace.router.schemas.model import ErrorModel, ValidationErrorModel
T = TypeVar("T")
class UniResponseModel(BaseModel, Generic[T]):
"""
统一响应模型适用于所有API响应。
Attributes:
success (bool): 操作是否成功。
message (str | None): 操作的详细信息。
data (ResponseModel | None): 操作返回的数据。
error (DetailModel | None): 操作错误信息,支持 ErrorModel 或 ValidationErrorDetail。
timestamp (str): 响应生成的时间戳,格式为 "YYYY-MM-DD HH:MM:SS"
"""
success: bool = Field(..., description="操作是否成功")
message: str | None = Field(..., description="操作的详细信息")
data: T | None = Field(..., description="操作返回的数据")
error: Union[ErrorModel, ValidationErrorModel] | None = Field(
None, description="操作错误信息"
)
timestamp: str = Field(
default_factory=lambda: time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
description="响应生成的时间戳",
)
@classmethod
def from_response(
cls,
success: bool,
message: str,
data: T | None = None,
) -> "UniResponseModel":
return cls(
success=success,
message=message,
data=data,
error=None,
)

View File

@@ -0,0 +1,13 @@
from abc import abstractmethod
class Service:
@abstractmethod
async def initialize(self):
"""初始化服务"""
pass
@abstractmethod
async def shutdown(self):
"""关闭服务"""
pass

View File

@@ -0,0 +1,441 @@
import asyncio
import binascii
import logging
import re
import uuid
from asyncio import Task
from base64 import b64encode
from datetime import datetime
from typing import Dict, Type, TypeVar
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding as symmetric_padding
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from httpx import AsyncClient, RequestError
from httpx._types import HeaderTypes
from loveace.config.logger import LoggerMixin
from loveace.config.manager import config_manager
from loveace.service.model.service import Service
from loveace.service.remote.aufe.model.status import (
ECCheckStatus,
ECLoginStatus,
UAAPLoginStatus,
)
# 设置 HTTPX 日志级别为 CRITICAL
if not config_manager.get_settings().app.debug:
logging.getLogger("httpx").setLevel(logging.CRITICAL)
class SubClient:
async def aclose(self): ...
T_SubClient = TypeVar("T_SubClient", bound=SubClient)
class AUFEConnection:
userid: str
ec_password: str
password: str
_client: AsyncClient
twf_id: str
last_check: datetime
ec_logged: bool = False
uaap_logged: bool = False
trace_id: str
timeout: int = 30
_sub_clients: Dict[str, SubClient] = {}
def __init__(self, userid: str, ec_password: str, password: str):
self.userid = userid
self.ec_password = ec_password
self.password = password
self.last_check = datetime.now()
self.trace_id = str(uuid.uuid4().hex)
self.timeout = config_manager.get_settings().aufe.default_timeout
self.logger.info(
f"创建AUFE连接用户ID: {self.userid}, Trace ID: {self.trace_id},超时: {self.timeout}s"
)
@property
def logger(self) -> LoggerMixin:
return LoggerMixin(user_id=self.userid, trace_id=self.trace_id)
def start_client(self):
self._client = AsyncClient()
def health_checkpoint(self):
self.last_check = datetime.now()
async def health_check(self) -> bool:
delta = datetime.now() - self.last_check
self.logger.info(
f"AUFE连接健康检查距离上次检查时间: {delta.total_seconds()}"
)
if delta.total_seconds() > 300: # 5分钟未检查则视为不健康
self.logger.warning("AUFE连接不健康已超过5分钟未检查将自动关闭")
return False
if self._client.is_closed:
self.logger.warning("AUFE连接已关闭")
return False
check_uaap = await self.check_uaap_login_status()
if not check_uaap.logged_in:
self.logger.warning("UAAP登录状态无效可能需要重新登录")
return False
check_ec = await self.check_ec_login_status()
if not check_ec.logged_in:
self.logger.warning("EC登录状态无效可能需要重新登录")
return False
return True
def inject_subclient(self, name: str, sub_client: SubClient):
"""
注入子客户端
该方法用于将子客户端的关闭方法绑定到主客户端上
以便在关闭主客户端时也能关闭子客户端
Args:
sub_client (SubClient): 子客户端实例,必须实现 aclose 方法
"""
self.logger.info(f"注入子客户端 {name},类型: {type(sub_client).__name__}")
self._sub_clients[name] = sub_client
def get_subclient(
self, name: str, type_sub_client: Type[T_SubClient]
) -> T_SubClient | None:
"""
获取已注入的子客户端
Args:
name (str): 子客户端名称
type_sub_client (Type[T_SubClient]): 子客户端类型,用于类型检查
Returns:
T_SubClient: 子客户端实例
Raises:
ValueError: 如果子客户端不存在或类型不匹配
"""
if name not in self._sub_clients:
return None
sub_client = self._sub_clients[name]
if not isinstance(sub_client, type_sub_client):
return None
return sub_client
async def close_client(self):
await self._client.aclose()
for sub_client in self._sub_clients.values():
self.logger.info(f"正在关闭子客户端 {type(sub_client).__name__}")
await sub_client.aclose()
self._sub_clients.clear()
async def ec_login(self) -> ECLoginStatus:
"""
使用用户名和密码登录AUFE
"""
try:
# 初始请求获取认证参数
response = await self._client.get(
f"{config_manager.get_settings().aufe.server_url}/por/login_auth.csp?apiversion=1"
)
if twfid_g := re.search(r"<TwfID>(.*)</TwfID>", response.text):
self.twf_id = twfid_g.group(1)
else:
self.logger.error("错误: 响应中未找到TwfID。")
return ECLoginStatus(fail_not_found_twfid=True)
self.logger.info(f"Twf Id: {self.twf_id[:5]}******")
if rsa_key_g := re.search(
r"<RSA_ENCRYPT_KEY>(.*)</RSA_ENCRYPT_KEY>", response.text
):
rsa_key = rsa_key_g.group(1)
else:
self.logger.error("错误: 响应中未找到RSA_ENCRYPT_KEY。")
return ECLoginStatus(fail_not_found_rsa_key=True)
self.logger.info(f"RSA密钥: {rsa_key[:5]}******")
if rsa_exp_match := re.search(
r"<RSA_ENCRYPT_EXP>(.*)</RSA_ENCRYPT_EXP>", response.text
):
rsa_exp = rsa_exp_match.group(1)
else:
self.logger.error("错误: 响应中未找到RSA_ENCRYPT_EXP。")
return ECLoginStatus(fail_not_found_rsa_exp=True)
self.logger.info(f"RSA指数: {rsa_exp[:5]}******")
if csrf_match := re.search(
r"<CSRF_RAND_CODE>(.*)</CSRF_RAND_CODE>", response.text
):
csrf_code = csrf_match.group(1)
password_to_encrypt = self.password + "_" + csrf_code
else:
self.logger.error("错误: 响应中未找到CSRF_RAND_CODE。")
return ECLoginStatus(fail_not_found_csrf_code=True)
self.logger.info(f"CSRF代码: {csrf_code[:5]}******")
# 创建RSA密钥并加密密码
rsa_exp_int = int(rsa_exp)
rsa_modulus = int(rsa_key, 16)
public_numbers = rsa.RSAPublicNumbers(e=rsa_exp_int, n=rsa_modulus)
public_key = public_numbers.public_key(default_backend())
encrypted_password = public_key.encrypt(
password_to_encrypt.encode("utf-8"), padding.PKCS1v15()
)
encrypted_password_hex = binascii.hexlify(encrypted_password).decode(
"ascii"
)
self.logger.info(f"加密后密码: {encrypted_password_hex[:5]}******")
self.logger.info("开始执行登录请求")
login_response = await self._client.post(
f"{config_manager.get_settings().aufe.server_url}/por/login_psw.csp?anti_replay=1&encrypt=1&type=cs",
data={
"svpn_rand_code": "",
"mitm": "",
"svpn_req_randcode": csrf_code,
"svpn_name": self.userid,
"svpn_password": encrypted_password_hex,
},
cookies={"TWFID": self.twf_id},
timeout=10000,
)
self.logger.info(f"登录响应: {login_response.text[:10]}******")
# 检查登录结果
if "<Result>1</Result>" in login_response.text:
self.logger.info("登录成功")
self._client.cookies.set("TWFID", self.twf_id)
self.ec_logged = True
return ECLoginStatus(success=True)
elif "Invalid username or password!" in login_response.text:
self.logger.error("登录失败: 用户名或密码错误")
return ECLoginStatus(fail_invalid_credentials=True)
elif "[CDATA[maybe attacked]]" in login_response.text or "CAPTCHA required" in login_response.text:
self.logger.error("登录失败: 可能受到攻击或需要验证码")
return ECLoginStatus(fail_maybe_attacked=True)
else:
self.logger.error(f"登录失败: {login_response.text}")
return ECLoginStatus(fail_unknown_error=True)
except RequestError as e:
self.logger.error(f"登录连接错误: {str(e)}")
return ECLoginStatus(fail_network_error=True)
except Exception as e:
self.logger.error(f"登录失败: {e}")
return ECLoginStatus(fail_unknown_error=True)
async def check_ec_login_status(self) -> ECCheckStatus:
"""
检查当前登录状态
"""
if not self.ec_logged:
return ECCheckStatus(logged_in=False)
try:
response = await self._client.get(
config_manager.get_settings().aufe.ec_check_url,
)
if response.status_code == 200:
self.logger.info("登录状态有效")
return ECCheckStatus(logged_in=True)
else:
self.logger.warning("登录状态无效,可能需要重新登录")
return ECCheckStatus(logged_in=False)
except RequestError as e:
self.logger.error(f"检查登录状态连接错误: {str(e)}")
return ECCheckStatus(fail_network_error=True)
except Exception as e:
self.logger.error(f"检查登录状态失败: {e}")
return ECCheckStatus(fail_unknown_error=True)
async def uaap_login(self) -> UAAPLoginStatus:
"""
使用用户名和密码登录UAAP
"""
try:
# 初始请求获取登录页面
response = await self._client.get(
config_manager.get_settings().aufe.uaap_login_url
)
if lt_match := re.search(r'name="lt" value="(.*?)"', response.text):
lt_value = lt_match.group(1)
else:
self.logger.error("错误: 登录页面中未找到lt参数。")
return UAAPLoginStatus(fail_not_found_lt=True)
self.logger.info(f"lt参数: {lt_value[:5]}******")
if execution_match := re.search(
r'name="execution" value="(.*?)"', response.text
):
execution_value = execution_match.group(1)
else:
self.logger.error("错误: 登录页面中未找到execution参数。")
return UAAPLoginStatus(fail_not_found_execution=True)
self.logger.info(f"execution参数: {execution_value[:5]}******")
# 处理密钥 - CryptoJS使用的是8字节密钥
key_bytes = lt_value.encode("utf-8")[:8]
# 如果密钥不足8字节则用0填充
if len(key_bytes) < 8:
key_bytes = key_bytes + b"\0" * (8 - len(key_bytes))
# 处理明文数据 - 确保是字节类型
password_bytes = self.password.encode("utf-8")
# 使用PKCS7填充
padder = symmetric_padding.PKCS7(64).padder()
padded_data = padder.update(password_bytes) + padder.finalize()
# 创建DES加密器 - ECB模式
cipher = Cipher(
algorithms.TripleDES(key_bytes), modes.ECB(), backend=default_backend()
)
encryptor = cipher.encryptor()
# 加密数据
encrypted = encryptor.update(padded_data) + encryptor.finalize()
# 提交登录表单
login_response = await self._client.post(
config_manager.get_settings().aufe.uaap_login_url,
data={
"username": self.userid,
"password": b64encode(encrypted).decode("utf-8"),
"lt": lt_value,
"execution": execution_value,
"_eventId": "submit",
"submit": "LOGIN",
},
timeout=10000,
)
# 检查登录结果
if (
login_response.status_code == 302
and "Location" in login_response.headers
):
redirect_url = login_response.headers["Location"]
if redirect_url.startswith(
config_manager.get_settings().aufe.uaap_check_url
):
self.logger.info("UAAP登录成功")
self.uaap_logged = True
return UAAPLoginStatus(success=True)
elif "Invalid username or password" in login_response.text:
self.logger.error("UAAP登录失败: 用户名或密码错误")
return UAAPLoginStatus(fail_invalid_credentials=True)
else:
self.logger.error(f"UAAP登录失败: {login_response.text}")
return UAAPLoginStatus(fail_unknown_error=True)
return UAAPLoginStatus(fail_unknown_error=True)
except RequestError as e:
self.logger.error(f"UAAP登录连接错误: {str(e)}")
return UAAPLoginStatus(fail_network_error=True)
except Exception as e:
self.logger.error(f"UAAP登录失败: {e}")
return UAAPLoginStatus(fail_unknown_error=True)
async def check_uaap_login_status(self) -> ECCheckStatus:
"""
检查当前UAAP登录状态
"""
return ECCheckStatus(logged_in=self.uaap_logged)
@property
def client(self) -> AsyncClient:
"""
获取HTTP客户端实例
注意: 此客户端只适用于教务系统,其他系统请查看具体 Service 实现
"""
self.health_checkpoint()
return self._client
@property
def empty_client(self, headers: HeaderTypes | None = None) -> AsyncClient:
"""
获取一个新的空白HTTP客户端实例用于子系统构建请求
"""
self.health_checkpoint()
return AsyncClient(headers=headers)
class AUFEService(Service):
"""
AUFE服务类
该类用于管理多个AUFE连接实例提供获取或创建连接的功能
并定期清理不健康的连接
"""
sessions: dict[str, AUFEConnection] = {}
logger: LoggerMixin
task: Task
def __init__(self):
# AUFEService 的 logger 不需要 trace_id因为它是服务级别的日志
self.logger = LoggerMixin(user_id="AUFEService", trace_id="")
async def get_or_create_connection(
self, userid: str, ec_password: str, password: str
) -> AUFEConnection:
"""
获取或创建AUFE连接
该方法会检查现有连接的健康状态,如果不健康则重新创建连接
注意,获取实例后请尽快操作登录,否则可能因为连接不健康而需要重新创建
Args:
userid (str): 用户ID
ec_password (str): EC系统密码
password (str): UAAP密码
Returns:
AUFEConnection: AUFE连接实例
"""
if userid not in self.sessions:
self.sessions[userid] = AUFEConnection(
userid=userid, ec_password=ec_password, password=password
)
self.sessions[userid].start_client()
return self.sessions[userid]
return self.sessions[userid]
async def _loop_cleanup(self):
"""
清理不健康的AUFE连接
"""
to_remove = []
for userid, connection in self.sessions.items():
if not await connection.health_check():
self.logger.info(f"用户 {userid} 的AUFE连接不健康正在关闭连接")
await connection.close_client()
self.logger.info(f"用户 {userid} 的AUFE连接已关闭正在移除连接")
to_remove.append(userid)
self.logger.info(f"用户 {userid} 的AUFE连接已移除")
for userid in to_remove:
del self.sessions[userid]
async def loop_cleanup_task(self):
"""
定期清理不健康的AUFE连接 ASYNC TASK
该任务每5分钟运行一次检查所有连接的健康状态并清理不健康的连接
该任务应在应用启动时运行,并在应用关闭时取消
"""
while True:
await asyncio.sleep(60) # 每分钟检查一次
await self._loop_cleanup()
async def initialize(self):
"""
初始化AUFE服务
该方法在应用启动时调用,用于启动清理任务
"""
self.logger.info("初始化AUFE服务")
self.task = asyncio.create_task(self.loop_cleanup_task())
self.logger.info("AUFE服务初始化完成")
async def shutdown(self):
"""
关闭AUFE服务
该方法在应用关闭时调用,用于关闭所有连接
"""
self.logger.info("关闭AUFE服务")
for userid, connection in self.sessions.items():
self.logger.info(f"正在关闭用户 {userid} 的AUFE连接")
await connection.close_client()
self.logger.info(f"用户 {userid} 的AUFE连接已关闭")
self.sessions.clear()
self.task.cancel()
try:
await self.task
except asyncio.CancelledError:
self.logger.info("AUFE服务已关闭")
pass

View File

@@ -0,0 +1,178 @@
from fastapi import Depends, HTTPException
from loveace.database.auth.user import ACEUser
from loveace.router.dependencies.auth import get_user_by_token
from loveace.router.schemas.error import ProtectRouterErrorToCode
from loveace.router.schemas.exception import UniResponseHTTPException
from loveace.service.remote.aufe import AUFEConnection, AUFEService
from loveace.utils.rsa import RSAUtils
service = AUFEService()
rsa = RSAUtils.get_or_create_rsa_utils()
async def get_aufe_service() -> AUFEService:
"""获取AUFE服务实例"""
return service
async def get_aufe_conn(
user: ACEUser = Depends(get_user_by_token),
) -> AUFEConnection:
"""获取用户的AUFE连接"""
service = await get_aufe_service()
conn = await service.get_or_create_connection(
user.userid,
ec_password=rsa.decrypt(user.ec_password),
password=rsa.decrypt(user.password),
)
logger = conn.logger
# 同步当前请求的 trace_id 到连接的 logger
conn.logger.trace_id = logger.trace_id
if conn.ec_logged and conn.uaap_logged:
logger.info(f"用户 {user.userid} 的AUFE连接已登录且可用")
return conn
try:
# 测试连接是否可用
if (await conn.check_ec_login_status()).logged_in:
logger.info(f"用户 {user.userid} 的AUFE连接仍然可用")
if (await conn.check_uaap_login_status()).logged_in:
logger.info(f"用户 {user.userid} 的UAAP连接仍然可用")
return conn
else:
logger.info(f"用户 {user.userid} 的UAAP连接不可用尝试重新登录")
# UAAP登录重试机制 (最多3次)
uaap_login_status = None
for uaap_retry in range(3):
uaap_login_status = await conn.uaap_login()
if uaap_login_status.success:
break
# 如果是密码错误,直接退出重试
if uaap_login_status.fail_invalid_credentials:
logger.error(
f"用户 {user.userid} UAAP登录失败 (密码错误),停止重试"
)
break
logger.info(
f"用户 {user.userid} UAAP登录重试第 {uaap_retry + 1}"
)
if not uaap_login_status or not uaap_login_status.success:
if uaap_login_status and uaap_login_status.fail_invalid_credentials:
logger.error(
f"用户 {user.userid} 的UAAP连接重新登录失败可能是密码错误"
)
raise ProtectRouterErrorToCode().user_need_reset_password.to_http_exception(
logger.trace_id
)
else:
logger.error(f"用户 {user.userid} 的UAAP连接重新登录失败")
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
logger.trace_id
)
if (await conn.check_uaap_login_status()).logged_in:
logger.info(f"用户 {user.userid} 的UAAP连接重新登录成功")
return conn
else:
logger.error(f"用户 {user.userid} 的UAAP连接重新登录失败")
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
logger.trace_id
)
else:
logger.info(f"用户 {user.userid} 的AUFE连接不可用尝试重新登录")
# EC登录重试机制 (最多3次)
ec_login_status = None
for ec_retry in range(3):
ec_login_status = await conn.ec_login()
if ec_login_status.success:
break
# 如果是攻击防范或密码错误,直接退出重试
if (
ec_login_status.fail_maybe_attacked
or ec_login_status.fail_invalid_credentials
):
logger.error(
f"用户 {user.userid} EC登录失败 (攻击防范或密码错误),停止重试"
)
break
logger.info(
f"用户 {user.userid} EC登录重试第 {ec_retry + 1}"
)
if not ec_login_status or not ec_login_status.success:
if ec_login_status and ec_login_status.fail_invalid_credentials:
logger.error(
f"用户 {user.userid} 的AUFE连接重新登录失败可能是密码错误"
)
raise ProtectRouterErrorToCode().user_need_reset_password.to_http_exception(
logger.trace_id
)
else:
logger.error(f"用户 {user.userid} 的AUFE连接重新登录失败")
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
logger.trace_id
)
if (await conn.check_ec_login_status()).logged_in:
logger.info(f"用户 {user.userid} 的AUFE连接重新登录成功")
# UAAP登录重试机制 (最多3次)
uaap_login_status = None
for uaap_retry in range(3):
uaap_login_status = await conn.uaap_login()
if uaap_login_status.success:
break
# 如果是密码错误,直接退出重试
if uaap_login_status.fail_invalid_credentials:
logger.error(
f"用户 {user.userid} UAAP登录失败 (密码错误),停止重试"
)
break
logger.info(
f"用户 {user.userid} UAAP登录重试第 {uaap_retry + 1}"
)
if not uaap_login_status or not uaap_login_status.success:
if uaap_login_status and uaap_login_status.fail_invalid_credentials:
logger.error(
f"用户 {user.userid} 的UAAP连接重新登录失败可能是密码错误"
)
raise ProtectRouterErrorToCode().user_need_reset_password.to_http_exception(
logger.trace_id
)
else:
logger.error(f"用户 {user.userid} 的UAAP连接重新登录失败")
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
logger.trace_id
)
if (await conn.check_uaap_login_status()).logged_in:
logger.info(f"用户 {user.userid} 的UAAP连接重新登录成功")
return conn
else:
logger.error(f"用户 {user.userid} 的UAAP连接重新登录失败")
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
logger.trace_id
)
else:
logger.error(f"用户 {user.userid} 的AUFE连接重新登录失败")
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
logger.trace_id
)
except (HTTPException, UniResponseHTTPException):
raise
except Exception as e:
logger.exception(e)
raise ProtectRouterErrorToCode().remote_service_error.to_http_exception(
logger.trace_id
)

View File

@@ -0,0 +1,28 @@
from pydantic import BaseModel
class ECLoginStatus(BaseModel):
success: bool = False
fail_not_found_twfid: bool = False
fail_not_found_rsa_key: bool = False
fail_not_found_rsa_exp: bool = False
fail_not_found_csrf_code: bool = False
fail_invalid_credentials: bool = False
fail_maybe_attacked: bool = False
fail_network_error: bool = False
fail_unknown_error: bool = False
class ECCheckStatus(BaseModel):
logged_in: bool = False
fail_network_error: bool = False
fail_unknown_error: bool = False
class UAAPLoginStatus(BaseModel):
success: bool = False
fail_not_found_lt: bool = False
fail_not_found_execution: bool = False
fail_invalid_credentials: bool = False
fail_network_error: bool = False
fail_unknown_error: bool = False

View File

@@ -0,0 +1,367 @@
import os
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, BinaryIO, Dict, Optional
import aioboto3
from botocore.client import Config as BotoCoreConfig
from types_aiobotocore_s3 import S3Client
from loveace.config.logger import logger
from loveace.config.manager import config_manager
from loveace.service.model.service import Service
from loveace.service.remote.s3.model.s3 import (
S3CopyResult,
S3ListResult,
S3Object,
S3UploadResult,
)
s3_config = config_manager.get_settings().s3
# Boto3 很诡异的问题,不把这两个参数设为 when_required 他会把 check 直接塞到 rawfile 里
# 阅读了一下应该是国内的一些 S3 兼容服务不能识读 checksum 导致的
os.environ["AWS_REQUEST_CHECKSUM_CALCULATION"] = "when_required"
os.environ["AWS_RESPONSE_CHECKSUM_VALIDATION"] = "when_required"
# 验证 S3 配置
if not all(
[
s3_config.endpoint_url,
s3_config.access_key_id,
s3_config.secret_access_key,
s3_config.bucket_name,
]
):
logger.warning("S3 配置不完整S3 功能将不可用")
raise ValueError("S3 配置不完整S3 功能将不可用")
class S3Service(Service):
"""类型提示完善的 aioboto3 S3 管理器"""
def __init__(self):
self._session: aioboto3.Session = aioboto3.Session()
self._bucket_name = s3_config.bucket_name
self._endpoint_url = s3_config.endpoint_url
self._client_config = {
"aws_access_key_id": s3_config.access_key_id,
"aws_secret_access_key": s3_config.secret_access_key,
"endpoint_url": s3_config.endpoint_url,
"region_name": s3_config.region_name,
"use_ssl": s3_config.use_ssl,
"config": BotoCoreConfig(
s3={
"addressing_style": s3_config.addressing_style,
"signature_version": s3_config.signature_version,
}
),
}
@asynccontextmanager
async def get_client(self) -> AsyncGenerator[S3Client, None]:
"""获取 S3 客户端上下文管理器"""
async with self._session.client("s3", **self._client_config) as client: # type: ignore
yield client
def _get_object_url(self, s3_key: str, bucket: Optional[str] = None) -> str:
"""
生成对象的直链 URL非预签名
Args:
s3_key: S3 对象键
bucket: 存储桶名称
Returns:
str: 直链 URL
"""
bucket_name = bucket or self._bucket_name
# 根据寻址风格构建 URL
if s3_config.addressing_style == "virtual":
# 虚拟主机风格https://bucket-name.endpoint/key
return f"https://{bucket_name}.{self._endpoint_url.replace('https://', '').replace('http://', '')}/{s3_key}"
else:
# 路径风格https://endpoint/bucket-name/key
return f"{self._endpoint_url}/{bucket_name}/{s3_key}"
async def upload_obj(
self,
file_obj: BinaryIO,
s3_key: str,
bucket: Optional[str] = None,
extra_args: Optional[Dict[str, Any]] = None,
) -> S3UploadResult:
"""
上传文件对象到 S3
Args:
file_obj: 文件对象
s3_key: S3 对象键
bucket: 存储桶名称
extra_args: 额外参数
Returns:
S3UploadResult: 上传结果,包含成功状态和直链 URL
"""
bucket_name = bucket or self._bucket_name
try:
async with self.get_client() as s3:
logger.info(f"开始上传文件对象到 S3: {s3_key}")
await s3.upload_fileobj(
file_obj, bucket_name, s3_key, ExtraArgs=extra_args
)
logger.info(f"文件对象上传成功: {s3_key}")
obj_url = self._get_object_url(s3_key, bucket_name)
return S3UploadResult(
success=True,
url=obj_url,
key=s3_key,
)
except Exception as e:
logger.error(f"文件对象上传失败 -> {s3_key}: {e}")
return S3UploadResult(
success=False,
key=s3_key,
error=str(e),
)
async def delete_object(self, s3_key: str, bucket: Optional[str] = None) -> bool:
"""
删除单个 S3 对象
Args:
s3_key: S3 对象键
bucket: 存储桶名称
Returns:
bool: 删除成功返回 True
"""
bucket_name = bucket or self._bucket_name
try:
async with self.get_client() as s3:
await s3.delete_object(Bucket=bucket_name, Key=s3_key)
logger.info(f"对象删除成功: {s3_key}")
return True
except Exception as e:
logger.error(f"对象删除失败 {s3_key}: {e}")
return False
async def list_objects(
self,
prefix: str = "",
bucket: Optional[str] = None,
max_keys: int = 1000,
continuation_token: Optional[str] = None,
) -> S3ListResult:
"""
列出 S3 对象
Args:
prefix: 对象键前缀
bucket: 存储桶名称
max_keys: 最大返回数量
continuation_token: 继续令牌,用于分页
Returns:
S3ListResult: 对象列表结果
"""
bucket_name = bucket or self._bucket_name
try:
async with self.get_client() as s3:
params: Dict[str, Any] = {
"Bucket": bucket_name,
"Prefix": prefix,
"MaxKeys": max_keys,
}
if continuation_token:
params["ContinuationToken"] = continuation_token
response = await s3.list_objects_v2(**params)
objects = []
if contents := response.get("Contents"):
for item in contents:
if key := item.get("Key"):
size = item.get("Size", 0)
last_mod = item.get("LastModified")
last_modified_str = last_mod.isoformat() if last_mod else ""
objects.append(
S3Object(
key=key,
size=size or 0,
last_modified=last_modified_str,
)
)
return S3ListResult(
success=True,
objects=objects,
prefix=prefix,
is_truncated=response.get("IsTruncated", False),
continuation_token=response.get("NextContinuationToken"),
)
except Exception as e:
logger.error(f"列出对象失败,前缀: {prefix}: {e}")
return S3ListResult(
success=False,
prefix=prefix,
error=str(e),
)
async def generate_presigned_url(
self,
s3_key: str,
bucket: Optional[str] = None,
expiration: int = 3600,
method: str = "get_object",
) -> Optional[str]:
"""
生成预签名 URL
Args:
s3_key: S3 对象键
bucket: 存储桶名称
expiration: URL 有效期(秒)
method: HTTP 方法get_object, put_object 等)
Returns:
Optional[str]: 预签名 URL生成失败返回 None
"""
bucket_name = bucket or self._bucket_name
try:
async with self.get_client() as s3:
url = await s3.generate_presigned_url(
ClientMethod=method,
Params={"Bucket": bucket_name, "Key": s3_key},
ExpiresIn=expiration,
)
logger.info(f"预签名 URL 生成成功: {s3_key}")
return url
except Exception as e:
logger.error(f"生成预签名 URL 失败 {s3_key}: {e}")
return None
async def generate_presigned_url_from_direct_url(
self,
direct_url: str,
expiration: int = 3600,
) -> Optional[str]:
"""
从直链 URL 生成预签名 URL
Args:
direct_url: 直链 URL
expiration: URL 有效期(秒)
Returns:
Optional[str]: 预签名 URL生成失败返回 None
"""
try:
# 解析出 bucket 和 key
if s3_config.addressing_style == "virtual":
# 虚拟主机风格https://bucket-name.endpoint/key
url_without_protocol = direct_url.replace("https://", "").replace(
"http://", ""
)
first_slash = url_without_protocol.find("/")
bucket_name = self._bucket_name
s3_key = url_without_protocol[first_slash + 1 :]
else:
# 路径风格https://endpoint/bucket-name/key
url_without_protocol = direct_url.replace("https://", "").replace(
"http://", ""
)
path_parts = url_without_protocol.split("/")
bucket_name = self._bucket_name
s3_key = "/".join(path_parts[2:])
return await self.generate_presigned_url(
s3_key=s3_key,
bucket=bucket_name,
expiration=expiration,
method="get_object",
)
except Exception as e:
logger.error(f"从直链 URL 生成预签名 URL 失败 {direct_url}: {e}")
return None
async def object_exists(self, s3_key: str, bucket: Optional[str] = None) -> bool:
"""
检查 S3 对象是否存在
Args:
s3_key: S3 对象键
bucket: 存储桶名称
Returns:
bool: 存在返回 True
"""
bucket_name = bucket or self._bucket_name
try:
async with self.get_client() as s3:
await s3.head_object(Bucket=bucket_name, Key=s3_key)
return True
except Exception:
return False
async def copy_object(
self,
source_key: str,
dest_key: str,
source_bucket: Optional[str] = None,
dest_bucket: Optional[str] = None,
) -> S3CopyResult:
"""
复制 S3 对象
Args:
source_key: 源对象键
dest_key: 目标对象键
source_bucket: 源存储桶名称
dest_bucket: 目标存储桶名称
Returns:
S3CopyResult: 复制结果,包含成功状态和目标直链 URL
"""
src_bucket_name = source_bucket or self._bucket_name
dst_bucket_name = dest_bucket or self._bucket_name
copy_source = {"Bucket": src_bucket_name, "Key": source_key}
try:
async with self.get_client() as s3:
await s3.copy_object(
CopySource=copy_source, # type: ignore
Bucket=dst_bucket_name,
Key=dest_key, # type: ignore
)
logger.info(f"对象复制成功: {source_key} -> {dest_key}")
return S3CopyResult(
success=True,
source_key=source_key,
dest_key=dest_key,
dest_url=self._get_object_url(dest_key, dst_bucket_name),
)
except Exception as e:
logger.error(f"对象复制失败 {source_key} -> {dest_key}: {e}")
return S3CopyResult(
success=False,
source_key=source_key,
dest_key=dest_key,
error=str(e),
)
async def initialize(self):
"""初始化 S3 服务"""
logger.info("S3 服务初始化完成")
async def shutdown(self):
"""关闭 S3 服务"""
logger.info("S3 服务已关闭")

View File

@@ -0,0 +1,8 @@
from loveace.service.remote.s3 import S3Service
s3 = S3Service()
async def get_s3_service() -> S3Service:
"""获取S3服务实例"""
return s3

View File

@@ -0,0 +1,59 @@
from typing import Optional
from pydantic import BaseModel
class S3UploadResult(BaseModel):
"""S3 上传结果"""
success: bool
"""上传是否成功"""
url: Optional[str] = None
"""直链 URL仅在上传成功时返回"""
key: Optional[str] = None
"""S3 对象键"""
error: Optional[str] = None
"""错误信息,仅在上传失败时返回"""
class S3CopyResult(BaseModel):
"""S3 复制结果"""
success: bool
"""复制是否成功"""
source_key: Optional[str] = None
"""源 S3 对象键"""
dest_key: Optional[str] = None
"""目标 S3 对象键"""
dest_url: Optional[str] = None
"""目标直链 URL仅在复制成功时返回"""
error: Optional[str] = None
"""错误信息,仅在复制失败时返回"""
class S3Object(BaseModel):
"""S3 对象基本信息"""
key: str
"""对象键"""
size: int
"""对象大小(字节)"""
last_modified: str
"""最后修改时间"""
class S3ListResult(BaseModel):
"""S3 列表操作结果"""
success: bool
"""操作是否成功"""
objects: list[S3Object] = []
"""对象列表"""
prefix: str = ""
"""前缀"""
is_truncated: bool = False
"""是否存在更多对象"""
continuation_token: Optional[str] = None
"""继续令牌,用于分页"""
error: Optional[str] = None
"""错误信息"""

View File

@@ -0,0 +1,547 @@
"""
Redis客户端工具模块
提供类型完整的Redis客户端包装器支持内容验证和序列化
"""
import json
from typing import Any, Optional, Type, TypeVar, Union
import redis.asyncio as aioredis
from pydantic import BaseModel, ValidationError
from loveace.config.logger import logger
from loveace.database.creator import db_manager
T = TypeVar("T", bound=BaseModel)
class RedisClient:
"""类型完整的Redis客户端包装器
提供带有数据验证和序列化的Redis操作接口
Example:
>>> client = RedisClient(redis_instance)
>>> # 存储对象
>>> await client.set_object("user:1", user_data, User)
>>> # 获取对象
>>> user = await client.get_object("user:1", User)
"""
def __init__(self, redis_client: aioredis.Redis):
"""初始化Redis客户端包装器
Args:
redis_client: aioredis.Redis 实例
"""
self.client = redis_client
async def set_object(
self,
key: str,
value: Union[BaseModel, dict, Any],
model_class: Optional[Type[T]] = None,
expire: Optional[int] = None,
) -> bool:
"""设置对象到Redis支持自动验证和序列化
Args:
key: Redis键
value: 要存储的值BaseModel、dict或其他可序列化对象
model_class: 对象模型类用于验证。如果提供会先验证value
expire: 过期时间None表示不设置过期时间
Returns:
是否成功设置
Raises:
ValidationError: 当model_class验证失败时
TypeError: 当value无法序列化时
"""
try:
# 验证数据
if model_class is not None:
if isinstance(value, model_class):
validated_value = value
else:
validated_value = model_class(
**value if isinstance(value, dict) else value.dict()
)
else:
validated_value = value
# 序列化
if isinstance(validated_value, BaseModel):
data = validated_value.model_dump_json()
elif isinstance(validated_value, dict):
data = json.dumps(validated_value, ensure_ascii=False)
else:
data = json.dumps(validated_value, ensure_ascii=False)
# 存储到Redis
if expire:
await self.client.setex(key, expire, data)
else:
await self.client.set(key, data)
logger.debug(f"成功存储Redis键: {key}")
return True
except ValidationError as e:
logger.error(f"Redis对象验证失败 {key}: {e}")
raise
except Exception as e:
logger.error(f"Redis存储失败 {key}: {e}")
raise
async def get_object(
self,
key: str,
model_class: Type[T],
) -> Optional[T]:
"""从Redis获取对象并通过指定的模型类进行验证
Args:
key: Redis键
model_class: 对象模型类,用于反序列化和验证
Returns:
反序列化并验证后的对象如果键不存在则返回None
Raises:
ValidationError: 当数据验证失败时
"""
try:
data = await self.client.get(key)
if data is None:
logger.debug(f"Redis键不存在: {key}")
return None
# 反序列化
if isinstance(data, bytes):
data = data.decode("utf-8")
parsed_data = json.loads(data)
# 验证并创建模型实例
validated_value = model_class(**parsed_data)
logger.debug(f"成功获取并验证Redis键: {key}")
return validated_value
except ValidationError as e:
logger.error(f"Redis对象验证失败 {key}: {e}")
raise
except json.JSONDecodeError as e:
logger.error(f"Redis JSON解析失败 {key}: {e}")
raise
except Exception as e:
logger.error(f"Redis获取失败 {key}: {e}")
raise
async def get_object_safe(
self,
key: str,
model_class: Type[T],
default: Optional[T] = None,
) -> Optional[T]:
"""安全地从Redis获取对象验证失败时返回默认值
Args:
key: Redis键
model_class: 对象模型类,用于反序列化和验证
default: 验证失败时的默认返回值
Returns:
反序列化并验证后的对象验证失败返回default
"""
try:
return await self.get_object(key, model_class)
except (ValidationError, json.JSONDecodeError, Exception) as e:
logger.warning(f"Redis安全获取失败返回默认值 {key}: {e}")
return default
async def set_raw(
self,
key: str,
value: Union[str, bytes],
expire: Optional[int] = None,
) -> bool:
"""设置原始字符串值到Redis
Args:
key: Redis键
value: 要存储的值(字符串或字节)
expire: 过期时间(秒)
Returns:
是否成功设置
"""
try:
if expire:
await self.client.setex(key, expire, value)
else:
await self.client.set(key, value)
logger.debug(f"成功存储原始值到Redis: {key}")
return True
except Exception as e:
logger.error(f"Redis原始值存储失败 {key}: {e}")
raise
async def get_raw(self, key: str) -> Optional[Union[str, bytes]]:
"""获取原始字符串值
Args:
key: Redis键
Returns:
存储的值如果键不存在则返回None
"""
try:
data = await self.client.get(key)
if data is None:
logger.debug(f"Redis键不存在: {key}")
return None
logger.debug(f"成功获取原始值: {key}")
return data
except Exception as e:
logger.error(f"Redis获取失败 {key}: {e}")
raise
async def delete(self, key: str) -> int:
"""删除Redis键
Args:
key: 要删除的键
Returns:
删除的键数量
"""
try:
result = await self.client.delete(key)
logger.debug(f"成功删除Redis键: {key}")
return result
except Exception as e:
logger.error(f"Redis删除失败 {key}: {e}")
raise
async def exists(self, key: str) -> bool:
"""检查键是否存在
Args:
key: 要检查的键
Returns:
键是否存在
"""
try:
return await self.client.exists(key) > 0
except Exception as e:
logger.error(f"Redis检查失败 {key}: {e}")
raise
async def expire(self, key: str, seconds: int) -> bool:
"""设置键的过期时间
Args:
key: Redis键
seconds: 过期时间(秒)
Returns:
是否成功设置
"""
try:
result = await self.client.expire(key, seconds)
logger.debug(f"成功设置Redis键过期时间: {key}, {seconds}")
return result > 0
except Exception as e:
logger.error(f"Redis设置过期失败 {key}: {e}")
raise
async def ttl(self, key: str) -> int:
"""获取键的剩余生存时间
Args:
key: Redis键
Returns:
剩余生存时间(秒),-1表示永不过期-2表示键不存在
"""
try:
return await self.client.ttl(key)
except Exception as e:
logger.error(f"Redis获取TTL失败 {key}: {e}")
raise
async def increment(
self,
key: str,
amount: int = 1,
) -> int:
"""增加键的值
Args:
key: Redis键
amount: 增加的数量
Returns:
增加后的值
"""
try:
result = await self.client.incrby(key, amount)
logger.debug(f"成功增加Redis键: {key}")
return result
except Exception as e:
logger.error(f"Redis增加失败 {key}: {e}")
raise
async def decrement(
self,
key: str,
amount: int = 1,
) -> int:
"""减少键的值
Args:
key: Redis键
amount: 减少的数量
Returns:
减少后的值
"""
try:
result = await self.client.decrby(key, amount)
logger.debug(f"成功减少Redis键: {key}")
return result
except Exception as e:
logger.error(f"Redis减少失败 {key}: {e}")
raise
async def list_push(
self,
key: str,
values: list[Union[BaseModel, dict, str]],
model_class: Optional[Type[T]] = None,
) -> int:
"""向列表推入元素
Args:
key: Redis键
values: 要推入的值列表
model_class: 对象模型类,用于验证每个值
Returns:
推入后列表的长度
"""
try:
serialized_values = []
for value in values:
if model_class is not None:
if isinstance(value, model_class):
validated_value = value
else:
if isinstance(value, dict):
validated_value = model_class(**value)
else:
validated_value = value
else:
validated_value = value
if isinstance(validated_value, BaseModel):
serialized_values.append(validated_value.model_dump_json())
elif isinstance(validated_value, dict):
serialized_values.append(
json.dumps(validated_value, ensure_ascii=False)
)
else:
serialized_values.append(str(validated_value))
result: int = await self.client.rpush(key, *serialized_values) # type: ignore
logger.debug(f"成功推入Redis列表: {key}")
return result
except Exception as e:
logger.error(f"Redis列表推入失败 {key}: {e}")
raise
async def list_range(
self,
key: str,
start: int = 0,
end: int = -1,
model_class: Optional[Type[T]] = None,
) -> list[Union[T, str]]:
"""获取列表范围内的元素
Args:
key: Redis键
start: 开始索引
end: 结束索引
model_class: 对象模型类用于反序列化。如果为None则返回原始字符串
Returns:
列表中指定范围的元素
"""
try:
data: list[Any] = await self.client.lrange(key, start, end) # type: ignore
if model_class is None:
return data
result = []
for item in data:
if isinstance(item, bytes):
item = item.decode("utf-8")
try:
parsed = json.loads(item)
result.append(model_class(**parsed))
except (json.JSONDecodeError, ValidationError):
result.append(item)
return result
except Exception as e:
logger.error(f"Redis列表获取失败 {key}: {e}")
raise
async def hash_set(
self,
key: str,
mapping: dict[str, Union[BaseModel, dict, str, int]],
model_class: Optional[Type[T]] = None,
) -> int:
"""设置哈希表字段
Args:
key: Redis键
mapping: 字段值映射
model_class: 对象模型类,用于验证值
Returns:
新添加的字段数
"""
try:
serialized_mapping = {}
for field, value in mapping.items():
if model_class is not None and not isinstance(value, (str, int, float)):
if isinstance(value, dict):
validated_value = model_class(**value)
else:
validated_value = value
if isinstance(validated_value, BaseModel):
serialized_mapping[field] = validated_value.model_dump_json()
else:
serialized_mapping[field] = str(value)
else:
serialized_mapping[field] = str(value)
result: int = await self.client.hset(key, mapping=serialized_mapping) # type: ignore
logger.debug(f"成功设置Redis哈希表: {key}")
return result
except Exception as e:
logger.error(f"Redis哈希表设置失败 {key}: {e}")
raise
async def hash_get(
self,
key: str,
field: str,
model_class: Optional[Type[T]] = None,
) -> Optional[Union[T, str]]:
"""获取哈希表字段值
Args:
key: Redis键
field: 字段名
model_class: 对象模型类,用于反序列化
Returns:
字段值如果不存在则返回None
"""
try:
data: Optional[Any] = await self.client.hget(key, field) # type: ignore
if data is None:
return None
if isinstance(data, bytes):
data = data.decode("utf-8")
if model_class is None:
return data
try:
parsed = json.loads(data)
return model_class(**parsed)
except (json.JSONDecodeError, ValidationError):
return data
except Exception as e:
logger.error(f"Redis哈希表获取失败 {key}:{field}: {e}")
raise
async def hash_get_all(
self,
key: str,
model_class: Optional[Type[T]] = None,
) -> dict[str, Union[T, str]]:
"""获取所有哈希表字段
Args:
key: Redis键
model_class: 对象模型类,用于反序列化值
Returns:
哈希表中的所有字段值
"""
try:
data: dict[Any, Any] = await self.client.hgetall(key) # type: ignore
if model_class is None:
return data
result = {}
for field, value in data.items():
if isinstance(value, bytes):
value = value.decode("utf-8")
try:
parsed = json.loads(value)
result[field] = model_class(**parsed)
except (json.JSONDecodeError, ValidationError):
result[field] = value
return result
except Exception as e:
logger.error(f"Redis哈希表全量获取失败 {key}: {e}")
raise
async def hash_delete(
self,
key: str,
*fields: str,
) -> int:
"""删除哈希表字段
Args:
key: Redis键
fields: 要删除的字段名
Returns:
删除的字段数
"""
try:
result: int = await self.client.hdel(key, *fields) # type: ignore
logger.debug(f"成功删除Redis哈希表字段: {key}")
return result
except Exception as e:
logger.error(f"Redis哈希表删除失败 {key}: {e}")
raise
async def get_redis_client() -> RedisClient:
"""获取全局Redis客户端实例
Returns:
aioredis.Redis 实例
"""
redis_instance = await db_manager.get_redis_client()
redis_client = RedisClient(redis_instance)
return redis_client

View File

@@ -0,0 +1,107 @@
import logging
import os
import sys
import types
from datetime import datetime
from logging import LogRecord
from pathlib import Path
from typing import Callable, Iterable, List, Optional, Union
from loguru import logger
from rich.console import Console, ConsoleRenderable
from rich.text import Text
from rich.theme import Theme
from rich.traceback import Traceback
from richuru import ExceptionHook, LoguruHandler, LoguruRichHandler, _loguru_exc_hook
class HookedLoguruRichHandler(LoguruRichHandler):
"""
A hooked version of LoguruRichHandler to fix some issues.
"""
def render(
self,
*,
record: LogRecord,
traceback: Optional[Traceback],
message_renderable: "ConsoleRenderable",
) -> "ConsoleRenderable":
"""Render log for display.
Args:
record (LogRecord): logging Record.
traceback (Optional[Traceback]): Traceback instance or None for no Traceback.
message_renderable (ConsoleRenderable): Renderable (typically Text) containing log message contents.
Returns:
ConsoleRenderable: Renderable to display log.
"""
current_path = Path(os.getcwd())
path = Path(record.pathname)
try:
path = path.relative_to(current_path)
if sys.platform == "win32":
path = str(path).replace("\\", "/")
except ValueError:
path = Path(record.pathname).name
path = str(path)
level = self.get_level_text(record)
time_format = None if self.formatter is None else self.formatter.datefmt
log_time = datetime.fromtimestamp(record.created)
log_renderable = self._log_render(
self.console,
[message_renderable] if not traceback else [message_renderable, traceback],
log_time=log_time,
time_format=time_format,
level=level,
path=path,
line_no=record.lineno,
link_path=record.pathname if self.enable_link_path else None,
)
return log_renderable
def install(
rich_console: Optional[Console] = None,
exc_hook: Optional[ExceptionHook] = _loguru_exc_hook,
rich_traceback: bool = True,
tb_ctx_lines: int = 3,
tb_theme: Optional[str] = None,
tb_suppress: Iterable[Union[str, types.ModuleType]] = (),
time_format: Union[str, Callable[[datetime], Text]] = "[%x %X]",
keywords: Optional[List[str]] = None,
level: Union[int, str] = 20,
) -> None:
"""Install Rich logging and Loguru exception hook"""
logging.basicConfig(handlers=[LoguruHandler()], level=0)
logger.configure(
handlers=[
{
"sink": HookedLoguruRichHandler(
console=rich_console
or Console(
theme=Theme(
{
"logging.level.success": "green",
"logging.level.trace": "bright_black",
}
)
),
rich_tracebacks=rich_traceback,
tracebacks_show_locals=True,
tracebacks_suppress=tb_suppress,
tracebacks_extra_lines=tb_ctx_lines,
tracebacks_theme=tb_theme,
show_time=False,
log_time_format=time_format,
keywords=keywords,
),
"format": (lambda _: "{message}") if rich_traceback else "{message}",
"level": level,
}
]
)
if exc_hook is not None:
sys.excepthook = exc_hook

332
loveace/utils/rsa.py Normal file
View File

@@ -0,0 +1,332 @@
import base64
import os
from contextvars import ContextVar
from pathlib import Path
from typing import Dict
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
from cryptography.hazmat.primitives.ciphers.aead import AESGCMSIV
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt
from loveace.config.manager import config_manager
console = Console()
rsa_context: ContextVar[Dict[str, "RSAUtils"]] = ContextVar("rsa_context")
class RSAUtils:
"""RSA 工具类,支持 AES-GCM-SIV 加密的密钥保护"""
private_key_path: str
private_key: RSAPrivateKey
public_key: RSAPublicKey
def __init__(self, private_key_path: str | None = None):
"""初始化 RSAUtils 类
Args:
private_key_path (str): 私钥文件路径
"""
settings = config_manager.get_settings()
self.private_key_path = str(
Path(settings.app.rsa_protect_key_path).joinpath(
Path(
private_key_path
or config_manager.get_settings().app.rsa_private_key_path
).name
)
)
# 转换路径扩展名为 .hex
self.private_key_path = str(self.private_key_path).replace(".pem", ".hex")
self.load_keys()
def _derive_key_from_password(
self, password: str, salt: bytes | None = None
) -> tuple[bytes, bytes]:
"""从密码派生 AES 密钥
Args:
password (str): 用户输入的密码
salt (bytes): 盐值,如果为 None 则生成新的
Returns:
tuple[bytes, bytes]: (派生密钥, 盐值)
"""
if salt is None:
salt = os.urandom(16)
kdf_obj = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=16, # AES-128 需要 16 字节密钥
salt=salt,
iterations=100000,
)
key = kdf_obj.derive(password.encode("utf-8"))
return key, salt
def load_keys(self):
"""加载密钥对(从加密的 AES 文件中)"""
path = Path(self.private_key_path)
console.print(
Panel(
f"[bold cyan]正在操作密钥文件[/bold cyan]\n"
f"[cyan]文件路径:{self.private_key_path}[/cyan]",
expand=False,
)
)
if not path.exists():
console.print(
Panel(
"[bold yellow]RSA 密钥对不存在,将为您生成新的密钥对[/bold yellow]",
title="[bold blue]密钥生成[/bold blue]",
expand=False,
)
)
self.generate_keys()
else:
self._load_encrypted_key()
def _load_encrypted_key(self):
"""从加密的 .hex 文件加载密钥"""
console.print(
Panel(
f"[bold cyan]检测到本地 RSA 私钥文件[/bold cyan]\n"
f"[cyan]文件路径:{self.private_key_path}[/cyan]",
title="[bold blue]密钥加载[/bold blue]",
expand=False,
)
)
console.print(
"[bold yellow]该密钥文件受密码保护,需要您输入密码来解密[/bold yellow]"
)
password = Prompt.ask(
"[bold]请输入 RSA 私钥密码[/bold]", password=True, console=console
)
with open(self.private_key_path, "rb") as key_file:
encrypted_data = key_file.read()
# 解析加密数据salt(16) + nonce(12) + ciphertext
salt = encrypted_data[:16]
nonce = encrypted_data[16:28]
ciphertext = encrypted_data[28:]
# 派生密钥
key, _ = self._derive_key_from_password(password, salt)
# 使用 AES-GCM-SIV 解密
try:
aesgcmsiv = AESGCMSIV(key)
plaintext = aesgcmsiv.decrypt(nonce, ciphertext, None)
console.print("[bold green]✓ 私钥密码验证成功[/bold green]")
except Exception as e:
console.print(
Panel(
"[bold red]✗ 私钥密码错误或密钥文件已损坏[/bold red]",
title="[bold red]错误[/bold red]",
expand=False,
)
)
raise ValueError("Invalid password or corrupted key file") from e
# 加载 PEM 格式的私钥
try:
pk = serialization.load_pem_private_key(
plaintext, password=None, backend=default_backend()
)
if isinstance(pk, RSAPrivateKey):
self.private_key = pk
else:
raise ValueError("Loaded key is not an RSA private key")
except Exception:
console.print(
Panel(
"[bold red]✗ 密钥格式错误[/bold red]",
title="[bold red]错误[/bold red]",
expand=False,
)
)
raise
self.public_key = self.private_key.public_key()
def generate_keys(self, key_size: int = 2048):
"""生成 RSA 密钥对并使用 AES 加密保存到文件
Args:
key_size (int): 密钥大小默认2048位
"""
path = Path(self.private_key_path)
path.parent.mkdir(parents=True, exist_ok=True)
# 提示用户设置密码
console.print(
Panel(
"[bold cyan]请设置 RSA 私钥密码(用于保护密钥文件)[/bold cyan]",
title="[bold blue]密钥保护[/bold blue]",
expand=False,
)
)
password = Prompt.ask("[bold]请输入密码[/bold]", password=True, console=console)
password_confirm = Prompt.ask(
"[bold]请确认密码[/bold]", password=True, console=console
)
if password != password_confirm:
console.print(
Panel(
"[bold red]✗ 两次输入的密码不一致[/bold red]",
title="[bold red]错误[/bold red]",
expand=False,
)
)
raise ValueError("Passwords do not match")
# 生成 RSA 密钥对
console.print("[bold cyan]正在生成 RSA 密钥对...[/bold cyan]")
private_key = rsa.generate_private_key(
public_exponent=65537, key_size=key_size, backend=default_backend()
)
public_key = private_key.public_key()
# 将私钥序列化为 PEM 格式
pem_private = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
pem_public = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
# 使用 AES-GCM-SIV 加密私钥
key, salt = self._derive_key_from_password(password)
aesgcmsiv = AESGCMSIV(key)
nonce = os.urandom(12)
ciphertext = aesgcmsiv.encrypt(nonce, pem_private, None)
# 保存加密的私钥salt + nonce + ciphertext
with open(self.private_key_path, "wb") as private_file:
private_file.write(salt + nonce + ciphertext)
# 保存公钥(不加密)
public_key_path = self.private_key_path.replace(".hex", "_public.pem")
with open(public_key_path, "wb") as public_file:
public_file.write(pem_public)
self.private_key = private_key
self.public_key = public_key
console.print(
Panel(
f"[bold green]✓ RSA 密钥对生成成功[/bold green]\n"
f"[cyan]私钥路径:[/cyan]{self.private_key_path}\n"
f"[cyan]公钥路径:[/cyan]{public_key_path}",
title="[bold blue]完成[/bold blue]",
expand=False,
)
)
def encrypt(self, plaintext: str) -> str:
"""使用公钥加密数据
Args:
plaintext (str): 明文字符串
Returns:
str: Base64 编码的密文字符串
"""
ciphertext = self.public_key.encrypt(
plaintext.encode("utf-8"),
padding.PKCS1v15(),
)
return base64.b64encode(ciphertext).decode("utf-8")
def decrypt(self, b64_ciphertext: str) -> str:
"""使用私钥解密数据
Args:
b64_ciphertext (str): Base64 编码的密文字符串
Returns:
str: 解密后的明文字符串
"""
ciphertext = base64.b64decode(b64_ciphertext)
plaintext = self.private_key.decrypt(
ciphertext,
padding.PKCS1v15(),
)
return plaintext.decode("utf-8")
@staticmethod
def encrypt_file_with_aes(
plaintext: bytes, password: str | None = None
) -> tuple[bytes, str]:
"""使用 AES-GCM-SIV 和密码加密数据
Args:
plaintext (bytes): 明文数据
password (str): 密码,如果为 None 则生成随机密钥
Returns:
tuple[bytes, str]: (加密数据, 密钥的十六进制字符串)
"""
if password is None:
# 生成随机密钥
key = AESGCMSIV.generate_key(bit_length=128)
aesgcmsiv = AESGCMSIV(key)
nonce = os.urandom(12)
ciphertext = aesgcmsiv.encrypt(nonce, plaintext, None)
encrypted_data = key + nonce + ciphertext
else:
# 从密码派生密钥
salt = os.urandom(16)
kdf_obj = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=16,
salt=salt,
iterations=100000,
)
key = kdf_obj.derive(password.encode("utf-8"))
aesgcmsiv = AESGCMSIV(key)
nonce = os.urandom(12)
ciphertext = aesgcmsiv.encrypt(nonce, plaintext, None)
encrypted_data = salt + nonce + ciphertext
key_hex = key.hex()
return encrypted_data, key_hex
@staticmethod
def get_or_create_rsa_utils(private_key_path: str | None = None) -> "RSAUtils":
"""
获取或创建 RSAUtils 实例
Args:
private_key_path (str | None): 私钥文件路径,如果为 None 则使用配置中的默认路径
"""
private_key_path = (
private_key_path or config_manager.get_settings().app.rsa_private_key_path
)
try:
rsa_utils_dict = rsa_context.get()
if private_key_path in rsa_utils_dict:
return rsa_utils_dict[private_key_path]
else:
rsa_utils = RSAUtils(private_key_path)
rsa_utils_dict[private_key_path] = rsa_utils
rsa_context.set(rsa_utils_dict)
return rsa_utils
except LookupError:
rsa_utils = RSAUtils(private_key_path)
rsa_utils_dict = {private_key_path: rsa_utils}
rsa_context.set(rsa_utils_dict)
return rsa_utils