⚒️ 重大重构 LoveACE V2

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
from typing import List
from pydantic import BaseModel, Field
# ==================== 电费相关模型 ====================
class ElectricityBalance(BaseModel):
"""电费余额信息"""
remaining_purchased: float = Field(..., description="剩余购电(度)")
remaining_subsidy: float = Field(..., description="剩余补助(度)")
class ElectricityUsageRecord(BaseModel):
"""用电记录"""
record_time: str = Field(..., description="记录时间2025-08-29 00:04:58")
usage_amount: float = Field(..., description="用电量(度)")
meter_name: str = Field(..., description="电表名称1-101 或 1-101空调")
# ==================== 充值相关模型 ====================
class PaymentRecord(BaseModel):
"""充值记录"""
payment_time: str = Field(..., description="充值时间2025-02-21 11:30:08")
amount: float = Field(..., description="充值金额(元)")
payment_type: str = Field(..., description="充值类型,如:下发补助、一卡通充值")
class UniISIMInfoResponse(BaseModel):
"""寝室电费信息"""
room_code: str = Field(..., description="寝室代码")
room_text: str = Field(..., description="寝室显示名称")
room_display: str = Field(..., description="寝室显示名称")
balance: ElectricityBalance = Field(..., description="电费余额")
usage_records: List[ElectricityUsageRecord] = Field(..., description="用电记录")
payments: List[PaymentRecord] = Field(..., description="充值记录")

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
def convert_zxjxjhh_to_term_format(zxjxjhh: str) -> str:
"""
转换学期格式
xxxx-yyyy-1-1 -> xxxx-yyyy秋季学期
xxxx-yyyy-2-1 -> xxxx-yyyy春季学期
Args:
zxjxjhh: 学期代码,如 "2025-2026-1-1"
Returns:
str: 转换后的学期名称,如 "2025-2026秋季学期"
"""
try:
parts = zxjxjhh.split("-")
if len(parts) >= 3:
year_start = parts[0]
year_end = parts[1]
semester_num = parts[2]
if semester_num == "1":
return f"{year_start}-{year_end}秋季学期"
elif semester_num == "2":
return f"{year_start}-{year_end}春季学期"
return zxjxjhh # 如果格式不匹配,返回原值
except Exception:
return zxjxjhh

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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