⚒️ 重大重构 LoveACE V2
引入了 mongodb 对数据库进行了一定程度的数据加密 性能改善 代码简化 统一错误模型和响应 使用 apifox 作为文档
This commit is contained in:
114
loveace/config/logger.py
Normal file
114
loveace/config/logger.py
Normal 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
173
loveace/config/manager.py
Normal 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
194
loveace/config/settings.py
Normal 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
|
||||
}
|
||||
14
loveace/database/aac/ticket.py
Normal file
14
loveace/database/aac/ticket.py
Normal 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())
|
||||
13
loveace/database/auth/login.py
Normal file
13
loveace/database/auth/login.py
Normal 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)
|
||||
20
loveace/database/auth/register.py
Normal file
20
loveace/database/auth/register.py
Normal 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)
|
||||
15
loveace/database/auth/token.py
Normal file
15
loveace/database/auth/token.py
Normal 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())
|
||||
16
loveace/database/auth/user.py
Normal file
16
loveace/database/auth/user.py
Normal 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)
|
||||
6
loveace/database/base/__init__.py
Normal file
6
loveace/database/base/__init__.py
Normal 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
149
loveace/database/creator.py
Normal 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()
|
||||
15
loveace/database/isim/room.py
Normal file
15
loveace/database/isim/room.py
Normal 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())
|
||||
1
loveace/database/ldjlb/__init__.py
Normal file
1
loveace/database/ldjlb/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 劳动俱乐部数据库模型
|
||||
14
loveace/database/ldjlb/ticket.py
Normal file
14
loveace/database/ldjlb/ticket.py
Normal 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())
|
||||
26
loveace/database/profile/flutter_profile.py
Normal file
26
loveace/database/profile/flutter_profile.py
Normal 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())
|
||||
17
loveace/database/profile/user_profile.py
Normal file
17
loveace/database/profile/user_profile.py
Normal 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())
|
||||
24
loveace/middleware/process_time.py
Normal file
24
loveace/middleware/process_time.py
Normal 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
|
||||
13
loveace/router/dependencies/__init__.py
Normal file
13
loveace/router/dependencies/__init__.py
Normal 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",
|
||||
]
|
||||
55
loveace/router/dependencies/auth.py
Normal file
55
loveace/router/dependencies/auth.py
Normal 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)
|
||||
11
loveace/router/dependencies/logger.py
Normal file
11
loveace/router/dependencies/logger.py
Normal 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)
|
||||
10
loveace/router/endpoint/aac/__init__.py
Normal file
10
loveace/router/endpoint/aac/__init__.py
Normal 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)
|
||||
185
loveace/router/endpoint/aac/credit.py
Normal file
185
loveace/router/endpoint/aac/credit.py
Normal 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, "获取爱安财分数明细未知异常,请稍后重试"
|
||||
)
|
||||
22
loveace/router/endpoint/aac/model/base.py
Normal file
22
loveace/router/endpoint/aac/model/base.py
Normal 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("/")
|
||||
40
loveace/router/endpoint/aac/model/credit.py
Normal file
40
loveace/router/endpoint/aac/model/credit.py
Normal 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="该类别下的分数明细列表"
|
||||
)
|
||||
167
loveace/router/endpoint/aac/utils/aac_ticket.py
Normal file
167
loveace/router/endpoint/aac/utils/aac_ticket.py
Normal 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
|
||||
30
loveace/router/endpoint/apifox.py
Normal file
30
loveace/router/endpoint/apifox.py
Normal 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/")
|
||||
10
loveace/router/endpoint/auth/__init__.py
Normal file
10
loveace/router/endpoint/auth/__init__.py
Normal 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)
|
||||
45
loveace/router/endpoint/auth/authme.py
Normal file
45
loveace/router/endpoint/auth/authme.py
Normal 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,
|
||||
)
|
||||
222
loveace/router/endpoint/auth/login.py
Normal file
222
loveace/router/endpoint/auth/login.py
Normal 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)
|
||||
6
loveace/router/endpoint/auth/model/authme.py
Normal file
6
loveace/router/endpoint/auth/model/authme.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AuthMeResponse(BaseModel):
|
||||
success: bool = Field(..., description="是否验证成功")
|
||||
userid: str = Field(..., description="用户ID")
|
||||
37
loveace/router/endpoint/auth/model/login.py
Normal file
37
loveace/router/endpoint/auth/model/login.py
Normal 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="服务器错误",
|
||||
)
|
||||
99
loveace/router/endpoint/auth/model/register.py
Normal file
99
loveace/router/endpoint/auth/model/register.py
Normal 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="服务器错误",
|
||||
)
|
||||
|
||||
|
||||
##############################################################
|
||||
247
loveace/router/endpoint/auth/register.py
Normal file
247
loveace/router/endpoint/auth/register.py
Normal 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)
|
||||
12
loveace/router/endpoint/isim/__init__.py
Normal file
12
loveace/router/endpoint/isim/__init__.py
Normal 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)
|
||||
74
loveace/router/endpoint/isim/elec.py
Normal file
74
loveace/router/endpoint/isim/elec.py
Normal 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
|
||||
)
|
||||
42
loveace/router/endpoint/isim/model/isim.py
Normal file
42
loveace/router/endpoint/isim/model/isim.py
Normal 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="充值记录")
|
||||
18
loveace/router/endpoint/isim/model/protect_router.py
Normal file
18
loveace/router/endpoint/isim/model/protect_router.py
Normal 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="房间缓存已过期,请稍后重新获取房间列表",
|
||||
)
|
||||
172
loveace/router/endpoint/isim/model/room.py
Normal file
172
loveace/router/endpoint/isim/model/room.py
Normal 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="数据更新时间")
|
||||
544
loveace/router/endpoint/isim/room.py
Normal file
544
loveace/router/endpoint/isim/room.py
Normal 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)
|
||||
1425
loveace/router/endpoint/isim/utils/isim.py
Normal file
1425
loveace/router/endpoint/isim/utils/isim.py
Normal file
File diff suppressed because it is too large
Load Diff
109
loveace/router/endpoint/isim/utils/lock_manager.py
Normal file
109
loveace/router/endpoint/isim/utils/lock_manager.py
Normal 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
|
||||
24
loveace/router/endpoint/isim/utils/room.py
Normal file
24
loveace/router/endpoint/isim/utils/room.py
Normal 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
|
||||
18
loveace/router/endpoint/jwc/__init__.py
Normal file
18
loveace/router/endpoint/jwc/__init__.py
Normal 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)
|
||||
245
loveace/router/endpoint/jwc/academic.py
Normal file
245
loveace/router/endpoint/jwc/academic.py
Normal 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
|
||||
)
|
||||
121
loveace/router/endpoint/jwc/competition.py
Normal file
121
loveace/router/endpoint/jwc/competition.py
Normal 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
|
||||
)
|
||||
97
loveace/router/endpoint/jwc/exam.py
Normal file
97
loveace/router/endpoint/jwc/exam.py
Normal 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
|
||||
)
|
||||
67
loveace/router/endpoint/jwc/model/academic.py
Normal file
67
loveace/router/endpoint/jwc/model/academic.py
Normal 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="是否可以选课")
|
||||
10
loveace/router/endpoint/jwc/model/base.py
Normal file
10
loveace/router/endpoint/jwc/model/base.py
Normal 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("/")
|
||||
84
loveace/router/endpoint/jwc/model/competition.py
Normal file
84
loveace/router/endpoint/jwc/model/competition.py
Normal 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="学分汇总详情")
|
||||
65
loveace/router/endpoint/jwc/model/exam.py
Normal file
65
loveace/router/endpoint/jwc/model/exam.py
Normal 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="座位号")
|
||||
348
loveace/router/endpoint/jwc/model/plan.py
Normal file
348
loveace/router/endpoint/jwc/model/plan.py
Normal 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" ", " ", 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" ", " ", 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
|
||||
49
loveace/router/endpoint/jwc/model/schedule.py
Normal file
49
loveace/router/endpoint/jwc/model/schedule.py
Normal 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="课程列表")
|
||||
28
loveace/router/endpoint/jwc/model/score.py
Normal file
28
loveace/router/endpoint/jwc/model/score.py
Normal 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="成绩记录列表")
|
||||
20
loveace/router/endpoint/jwc/model/term.py
Normal file
20
loveace/router/endpoint/jwc/model/term.py
Normal 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="星期几")
|
||||
262
loveace/router/endpoint/jwc/plan.py
Normal file
262
loveace/router/endpoint/jwc/plan.py
Normal 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
|
||||
)
|
||||
246
loveace/router/endpoint/jwc/schedule.py
Normal file
246
loveace/router/endpoint/jwc/schedule.py
Normal 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,
|
||||
)
|
||||
176
loveace/router/endpoint/jwc/score.py
Normal file
176
loveace/router/endpoint/jwc/score.py
Normal 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
|
||||
)
|
||||
306
loveace/router/endpoint/jwc/term.py
Normal file
306
loveace/router/endpoint/jwc/term.py
Normal 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
|
||||
)
|
||||
96
loveace/router/endpoint/jwc/utils/aspnet_form_parser.py
Normal file
96
loveace/router/endpoint/jwc/utils/aspnet_form_parser.py
Normal 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
|
||||
267
loveace/router/endpoint/jwc/utils/competition.py
Normal file
267
loveace/router/endpoint/jwc/utils/competition.py
Normal 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
|
||||
337
loveace/router/endpoint/jwc/utils/exam.py
Normal file
337
loveace/router/endpoint/jwc/utils/exam.py
Normal 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
|
||||
67
loveace/router/endpoint/jwc/utils/plan.py
Normal file
67
loveace/router/endpoint/jwc/utils/plan.py
Normal 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
|
||||
27
loveace/router/endpoint/jwc/utils/zxjxjhh_to_term_format.py
Normal file
27
loveace/router/endpoint/jwc/utils/zxjxjhh_to_term_format.py
Normal 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
|
||||
10
loveace/router/endpoint/ldjlb/__init__.py
Normal file
10
loveace/router/endpoint/ldjlb/__init__.py
Normal 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)
|
||||
703
loveace/router/endpoint/ldjlb/labor.py
Normal file
703
loveace/router/endpoint/ldjlb/labor.py
Normal 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, "获取活动详情未知异常,请稍后重试"
|
||||
)
|
||||
1
loveace/router/endpoint/ldjlb/model/__init__.py
Normal file
1
loveace/router/endpoint/ldjlb/model/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 劳动俱乐部数据模型
|
||||
22
loveace/router/endpoint/ldjlb/model/base.py
Normal file
22
loveace/router/endpoint/ldjlb/model/base.py
Normal 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("/")
|
||||
198
loveace/router/endpoint/ldjlb/model/ldjlb.py
Normal file
198
loveace/router/endpoint/ldjlb/model/ldjlb.py
Normal 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="教师列表")
|
||||
1
loveace/router/endpoint/ldjlb/utils/__init__.py
Normal file
1
loveace/router/endpoint/ldjlb/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 劳动俱乐部工具函数
|
||||
167
loveace/router/endpoint/ldjlb/utils/ldjlb_ticket.py
Normal file
167
loveace/router/endpoint/ldjlb/utils/ldjlb_ticket.py
Normal 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
|
||||
13
loveace/router/endpoint/profile/__init__.py
Normal file
13
loveace/router/endpoint/profile/__init__.py
Normal 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)
|
||||
427
loveace/router/endpoint/profile/flutter.py
Normal file
427
loveace/router/endpoint/profile/flutter.py
Normal 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)
|
||||
46
loveace/router/endpoint/profile/model/error.py
Normal file
46
loveace/router/endpoint/profile/model/error.py
Normal 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="服务器错误",
|
||||
)
|
||||
56
loveace/router/endpoint/profile/model/flutter.py
Normal file
56
loveace/router/endpoint/profile/model/flutter.py
Normal 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"
|
||||
24
loveace/router/endpoint/profile/model/user.py
Normal file
24
loveace/router/endpoint/profile/model/user.py
Normal 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值")
|
||||
8
loveace/router/endpoint/profile/model/uuid2s3key.py
Normal file
8
loveace/router/endpoint/profile/model/uuid2s3key.py
Normal 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值")
|
||||
339
loveace/router/endpoint/profile/user.py
Normal file
339
loveace/router/endpoint/profile/user.py
Normal 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,
|
||||
)
|
||||
35
loveace/router/endpoint/utils/alive.py
Normal file
35
loveace/router/endpoint/utils/alive.py
Normal 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()
|
||||
15
loveace/router/schemas/__init__.py
Normal file
15
loveace/router/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
178
loveace/router/schemas/base.py
Normal file
178
loveace/router/schemas/base.py
Normal 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
|
||||
65
loveace/router/schemas/error.py
Normal file
65
loveace/router/schemas/error.py
Normal 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",
|
||||
)
|
||||
11
loveace/router/schemas/exception.py
Normal file
11
loveace/router/schemas/exception.py
Normal 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
|
||||
19
loveace/router/schemas/model.py
Normal file
19
loveace/router/schemas/model.py
Normal 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="验证错误详情")
|
||||
45
loveace/router/schemas/uniresponse.py
Normal file
45
loveace/router/schemas/uniresponse.py
Normal 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,
|
||||
)
|
||||
13
loveace/service/model/service.py
Normal file
13
loveace/service/model/service.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from abc import abstractmethod
|
||||
|
||||
|
||||
class Service:
|
||||
@abstractmethod
|
||||
async def initialize(self):
|
||||
"""初始化服务"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def shutdown(self):
|
||||
"""关闭服务"""
|
||||
pass
|
||||
441
loveace/service/remote/aufe/__init__.py
Normal file
441
loveace/service/remote/aufe/__init__.py
Normal 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
|
||||
178
loveace/service/remote/aufe/depends.py
Normal file
178
loveace/service/remote/aufe/depends.py
Normal 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
|
||||
)
|
||||
28
loveace/service/remote/aufe/model/status.py
Normal file
28
loveace/service/remote/aufe/model/status.py
Normal 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
|
||||
367
loveace/service/remote/s3/__init__.py
Normal file
367
loveace/service/remote/s3/__init__.py
Normal 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 服务已关闭")
|
||||
8
loveace/service/remote/s3/depends.py
Normal file
8
loveace/service/remote/s3/depends.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from loveace.service.remote.s3 import S3Service
|
||||
|
||||
s3 = S3Service()
|
||||
|
||||
|
||||
async def get_s3_service() -> S3Service:
|
||||
"""获取S3服务实例"""
|
||||
return s3
|
||||
59
loveace/service/remote/s3/model/s3.py
Normal file
59
loveace/service/remote/s3/model/s3.py
Normal 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
|
||||
"""错误信息"""
|
||||
547
loveace/utils/redis_client.py
Normal file
547
loveace/utils/redis_client.py
Normal 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
|
||||
107
loveace/utils/richuru_hook.py
Normal file
107
loveace/utils/richuru_hook.py
Normal 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
332
loveace/utils/rsa.py
Normal 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
|
||||
Reference in New Issue
Block a user