⚒️ 重大重构 LoveACE V2
引入了 mongodb 对数据库进行了一定程度的数据加密 性能改善 代码简化 统一错误模型和响应 使用 apifox 作为文档
This commit is contained in:
10
loveace/router/endpoint/aac/__init__.py
Normal file
10
loveace/router/endpoint/aac/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from loveace.router.endpoint.aac.credit import aac_credit_router
|
||||
|
||||
aac_base_router = APIRouter(
|
||||
prefix="/aac",
|
||||
tags=["爱安财"],
|
||||
)
|
||||
|
||||
aac_base_router.include_router(aac_credit_router)
|
||||
185
loveace/router/endpoint/aac/credit.py
Normal file
185
loveace/router/endpoint/aac/credit.py
Normal file
@@ -0,0 +1,185 @@
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from httpx import Headers, HTTPError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from loveace.router.endpoint.aac.model.base import AACConfig
|
||||
from loveace.router.endpoint.aac.model.credit import (
|
||||
LoveACCreditCategory,
|
||||
LoveACCreditInfo,
|
||||
)
|
||||
from loveace.router.endpoint.aac.utils.aac_ticket import get_aac_header
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.uniresponse import UniResponseModel
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
|
||||
aac_credit_router = APIRouter(
|
||||
prefix="/credit",
|
||||
responses=ProtectRouterErrorToCode().gen_code_table(),
|
||||
)
|
||||
|
||||
ENDPOINT = {
|
||||
"total_score": "/User/Center/DoGetScoreInfo?sf_request_type=ajax",
|
||||
"score_list": "/User/Center/DoGetScoreList?sf_request_type=ajax",
|
||||
}
|
||||
|
||||
|
||||
@aac_credit_router.get(
|
||||
"/info",
|
||||
response_model=UniResponseModel[LoveACCreditInfo],
|
||||
summary="获取爱安财总分信息",
|
||||
)
|
||||
async def get_credit_info(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
headers: Headers = Depends(get_aac_header),
|
||||
) -> UniResponseModel[LoveACCreditInfo] | JSONResponse:
|
||||
"""
|
||||
获取用户的爱安财总分信息
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取爱安财总分和毕业要求状态
|
||||
- 获取未达标的原因说明
|
||||
- 实时从 AUFE 服务获取最新数据
|
||||
|
||||
💡 使用场景:
|
||||
- 个人中心显示爱安财总分
|
||||
- 检查是否满足毕业要求
|
||||
- 了解分数不足的原因
|
||||
|
||||
Returns:
|
||||
LoveACCreditInfo: 包含总分、达成状态和详细信息
|
||||
"""
|
||||
try:
|
||||
conn.logger.info("开始获取爱安财总分信息")
|
||||
response = await conn.client.post(
|
||||
url=AACConfig().to_full_url(ENDPOINT["total_score"]),
|
||||
data={},
|
||||
headers=headers,
|
||||
timeout=6000,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取爱安财总分信息失败,HTTP状态码: {response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财总分信息失败,请稍后重试"
|
||||
)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
conn.logger.error(f"获取爱安财总分信息失败,响应代码: {data.get('code')}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财总分信息失败,请稍后重试"
|
||||
)
|
||||
data = data.get("data", {})
|
||||
if not data:
|
||||
conn.logger.error("获取爱安财总分信息失败,响应数据为空")
|
||||
return ProtectRouterErrorToCode().null_response.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财总分信息失败,请稍后重试"
|
||||
)
|
||||
try:
|
||||
credit_info = LoveACCreditInfo.model_validate(data)
|
||||
conn.logger.info("成功获取爱安财总分信息")
|
||||
return UniResponseModel[LoveACCreditInfo](
|
||||
success=True,
|
||||
data=credit_info,
|
||||
message="获取爱安财总分信息成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"解析爱安财总分信息失败: {str(ve)}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id, "解析爱安财总分信息失败,请稍后重试"
|
||||
)
|
||||
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"获取爱安财总分信息异常: {str(he)}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财总分信息异常,请稍后重试"
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取爱安财总分信息未知异常: {str(e)}")
|
||||
return ProtectRouterErrorToCode().unknown_error.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财总分信息未知异常,请稍后重试"
|
||||
)
|
||||
|
||||
|
||||
@aac_credit_router.get(
|
||||
"/list",
|
||||
response_model=UniResponseModel[List[LoveACCreditCategory]],
|
||||
summary="获取爱安财分数明细",
|
||||
)
|
||||
async def get_credit_list(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
headers: Headers = Depends(get_aac_header),
|
||||
) -> UniResponseModel[List[LoveACCreditCategory]] | JSONResponse:
|
||||
"""
|
||||
获取用户的爱安财分数明细列表
|
||||
|
||||
✅ 功能特性:
|
||||
- 获取分数的详细分类信息
|
||||
- 显示每个分数项的具体内容
|
||||
- 支持分页查询
|
||||
|
||||
💡 使用场景:
|
||||
- 查看分数明细页面
|
||||
- 了解各类别分数构成
|
||||
- 分析分数不足的原因
|
||||
|
||||
Returns:
|
||||
list[LoveACCreditCategory]: 分数分类列表,每个分类包含多个分数项
|
||||
"""
|
||||
try:
|
||||
conn.logger.info("开始获取爱安财分数明细")
|
||||
response = await conn.client.post(
|
||||
url=AACConfig().to_full_url(ENDPOINT["score_list"]),
|
||||
data={"pageIndex": "1", "pageSize": "10"},
|
||||
headers=headers,
|
||||
timeout=6000,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
conn.logger.error(
|
||||
f"获取爱安财分数明细失败,HTTP状态码: {response.status_code}"
|
||||
)
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财分数明细失败,请稍后重试"
|
||||
)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
conn.logger.error(f"获取爱安财分数明细失败,响应代码: {data.get('code')}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财分数明细失败,请稍后重试"
|
||||
)
|
||||
data = data.get("data", [])
|
||||
if not data:
|
||||
conn.logger.error("获取爱安财分数明细失败,响应数据为空")
|
||||
return ProtectRouterErrorToCode().null_response.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财分数明细失败,请稍后重试"
|
||||
)
|
||||
try:
|
||||
credit_list = [LoveACCreditCategory.model_validate(item) for item in data]
|
||||
conn.logger.info("成功获取爱安财分数明细")
|
||||
return UniResponseModel[List[LoveACCreditCategory]](
|
||||
success=True,
|
||||
data=credit_list,
|
||||
message="获取爱安财分数明细成功",
|
||||
error=None,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
conn.logger.error(f"解析爱安财分数明细失败: {str(ve)}")
|
||||
return ProtectRouterErrorToCode().validation_error.to_json_response(
|
||||
conn.logger.trace_id, "解析爱安财分数明细失败,请稍后重试"
|
||||
)
|
||||
|
||||
except HTTPError as he:
|
||||
conn.logger.error(f"获取爱安财分数明细异常: {str(he)}")
|
||||
return ProtectRouterErrorToCode().remote_service_error.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财分数明细异常,请稍后重试"
|
||||
)
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取爱安财分数明细未知异常: {str(e)}")
|
||||
return ProtectRouterErrorToCode().unknown_error.to_json_response(
|
||||
conn.logger.trace_id, "获取爱安财分数明细未知异常,请稍后重试"
|
||||
)
|
||||
22
loveace/router/endpoint/aac/model/base.py
Normal file
22
loveace/router/endpoint/aac/model/base.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
|
||||
from loveace.config.manager import config_manager
|
||||
|
||||
settings = config_manager.get_settings()
|
||||
|
||||
|
||||
class AACConfig:
|
||||
"""AAC 模块配置常量"""
|
||||
|
||||
BASE_URL = "http://api-dekt-ac-acxk-net.vpn2.aufe.edu.cn:8118"
|
||||
WEB_URL = "http://dekt-ac-acxk-net.vpn2.aufe.edu.cn:8118"
|
||||
LOGIN_SERVICE_URL = "http://uaap-aufe-edu-cn.vpn2.aufe.edu.cn:8118/cas/login?service=http%3a%2f%2fapi.dekt.ac.acxk.net%2fUser%2fIndex%2fCoreLoginCallback%3fisCASGateway%3dtrue"
|
||||
RSA_PRIVATE_KEY_PATH = str(
|
||||
Path(settings.app.rsa_protect_key_path).joinpath("aac_private_key.pem")
|
||||
)
|
||||
|
||||
def to_full_url(self, path: str) -> str:
|
||||
"""将路径转换为完整URL"""
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
return path
|
||||
return self.BASE_URL.rstrip("/") + "/" + path.lstrip("/")
|
||||
40
loveace/router/endpoint/aac/model/credit.py
Normal file
40
loveace/router/endpoint/aac/model/credit.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LoveACCreditInfo(BaseModel):
|
||||
"""爱安财总分信息"""
|
||||
|
||||
total_score: float = Field(
|
||||
0.0, alias="TotalScore", description="总分,爱安财服务端已四舍五入"
|
||||
)
|
||||
is_type_adopt: bool = Field(
|
||||
False, alias="IsTypeAdopt", description="是否达到毕业要求"
|
||||
)
|
||||
type_adopt_result: str = Field(
|
||||
"", alias="TypeAdoptResult", description="未达到毕业要求的原因"
|
||||
)
|
||||
|
||||
|
||||
class LoveACCreditItem(BaseModel):
|
||||
"""爱安财分数明细条目"""
|
||||
|
||||
id: str = Field("", alias="ID", description="条目ID")
|
||||
title: str = Field("", alias="Title", description="条目标题")
|
||||
type_name: str = Field("", alias="TypeName", description="条目类别名称")
|
||||
user_no: str = Field("", alias="UserNo", description="用户编号,即学号")
|
||||
score: float = Field(0.0, alias="Score", description="分数")
|
||||
add_time: str = Field("", alias="AddTime", description="添加时间")
|
||||
|
||||
|
||||
class LoveACCreditCategory(BaseModel):
|
||||
"""爱安财分数类别"""
|
||||
|
||||
id: str = Field("", alias="ID", description="类别ID")
|
||||
show_num: int = Field(0, alias="ShowNum", description="显示序号")
|
||||
type_name: str = Field("", alias="TypeName", description="类别名称")
|
||||
total_score: float = Field(0.0, alias="TotalScore", description="类别总分")
|
||||
children: List[LoveACCreditItem] = Field(
|
||||
[], alias="children", description="该类别下的分数明细列表"
|
||||
)
|
||||
167
loveace/router/endpoint/aac/utils/aac_ticket.py
Normal file
167
loveace/router/endpoint/aac/utils/aac_ticket.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from urllib.parse import unquote
|
||||
|
||||
from fastapi import Depends
|
||||
from httpx import Headers
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from loveace.config.manager import config_manager
|
||||
from loveace.database.aac.ticket import AACTicket
|
||||
from loveace.database.creator import get_db_session
|
||||
from loveace.router.dependencies.auth import ProtectRouterErrorToCode
|
||||
from loveace.router.endpoint.aac.model.base import AACConfig
|
||||
from loveace.service.remote.aufe import AUFEConnection
|
||||
from loveace.service.remote.aufe.depends import get_aufe_conn
|
||||
from loveace.utils.rsa import RSAUtils
|
||||
|
||||
rsa = RSAUtils.get_or_create_rsa_utils(AACConfig.RSA_PRIVATE_KEY_PATH)
|
||||
|
||||
|
||||
def _extract_and_encrypt_token(location: str, logger) -> str | None:
|
||||
"""从重定向URL中提取并加密系统令牌"""
|
||||
try:
|
||||
sys_token = location.split("ticket=")[-1]
|
||||
# URL编码转为正常字符串
|
||||
sys_token = unquote(sys_token)
|
||||
if not sys_token:
|
||||
logger.error("系统令牌为空")
|
||||
return None
|
||||
|
||||
logger.info(f"获取到系统令牌: {sys_token[:10]}...")
|
||||
# 加密系统令牌
|
||||
encrypted_token = rsa.encrypt(sys_token)
|
||||
return encrypted_token
|
||||
except Exception as e:
|
||||
logger.error(f"解析/加密系统令牌失败: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_system_token(conn: AUFEConnection) -> str:
|
||||
next_location = AACConfig.LOGIN_SERVICE_URL
|
||||
max_redirects = 10 # 防止无限重定向
|
||||
redirect_count = 0
|
||||
try:
|
||||
while redirect_count < max_redirects:
|
||||
response = await conn.client.get(
|
||||
next_location, follow_redirects=False, timeout=conn.timeout
|
||||
)
|
||||
|
||||
# 如果是重定向,继续跟踪
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
next_location = response.headers.get("Location")
|
||||
if not next_location:
|
||||
conn.logger.error("重定向响应中缺少 Location 头")
|
||||
return ""
|
||||
|
||||
conn.logger.debug(f"重定向到: {next_location}")
|
||||
redirect_count += 1
|
||||
|
||||
if "register?ticket=" in next_location:
|
||||
conn.logger.info(f"重定向到爱安财注册页面: {next_location}")
|
||||
encrypted_token = _extract_and_encrypt_token(
|
||||
next_location, conn.logger
|
||||
)
|
||||
return encrypted_token if encrypted_token else ""
|
||||
else:
|
||||
break
|
||||
|
||||
if redirect_count >= max_redirects:
|
||||
conn.logger.error(f"重定向次数过多 ({max_redirects})")
|
||||
return ""
|
||||
|
||||
conn.logger.error("未能获取系统令牌")
|
||||
return ""
|
||||
|
||||
except Exception as e:
|
||||
conn.logger.error(f"获取系统令牌异常: {str(e)}")
|
||||
return ""
|
||||
|
||||
|
||||
async def get_aac_header(
|
||||
conn: AUFEConnection = Depends(get_aufe_conn),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Headers:
|
||||
"""
|
||||
获取AAC Ticket的依赖项。
|
||||
如果用户没有登录AUFE或UAAP,或者AAC Ticket不存在且无法获取新的Ticket,则会抛出HTTP异常。
|
||||
否则,返回有效的AAC Ticket字符串。
|
||||
"""
|
||||
# 检查AAC Ticket是否存在
|
||||
async with db as session:
|
||||
result = await session.execute(
|
||||
select(AACTicket).where(AACTicket.userid == conn.userid)
|
||||
)
|
||||
aac_ticket = result.scalars().first()
|
||||
|
||||
if not aac_ticket:
|
||||
aac_ticket = await _get_or_fetch_ticket(conn, db, is_new=True)
|
||||
else:
|
||||
aac_ticket_token = aac_ticket.aac_token
|
||||
try:
|
||||
# 解密以验证Ticket有效性
|
||||
decrypted_ticket = rsa.decrypt(aac_ticket_token)
|
||||
if not decrypted_ticket:
|
||||
raise ValueError("解密后的Ticket为空")
|
||||
aac_ticket = decrypted_ticket
|
||||
except Exception as e:
|
||||
conn.logger.error(
|
||||
f"用户 {conn.userid} 的 AAC Ticket 无效,正在获取新的 Ticket: {str(e)}"
|
||||
)
|
||||
aac_ticket = await _get_or_fetch_ticket(conn, db, is_new=False)
|
||||
else:
|
||||
conn.logger.info(f"用户 {conn.userid} 使用现有的 AAC Ticket")
|
||||
|
||||
return Headers(
|
||||
{
|
||||
**config_manager.get_settings().aufe.default_headers,
|
||||
"ticket": aac_ticket,
|
||||
"sdp-app-session": conn.twf_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _get_or_fetch_ticket(
|
||||
conn: AUFEConnection, db: AsyncSession, is_new: bool
|
||||
) -> str:
|
||||
"""获取或重新获取AAC Ticket并保存到数据库(返回解密后的ticket)"""
|
||||
action_type = "获取" if is_new else "重新获取"
|
||||
conn.logger.info(
|
||||
f"用户 {conn.userid} 的 AAC Ticket {'不存在' if is_new else '无效'},正在{action_type}新的 Ticket"
|
||||
)
|
||||
|
||||
encrypted_token = await get_system_token(conn)
|
||||
if not encrypted_token:
|
||||
conn.logger.error(f"用户 {conn.userid} {action_type} AAC Ticket 失败")
|
||||
raise ProtectRouterErrorToCode().remote_service_error.to_http_exception(
|
||||
conn.logger.trace_id,
|
||||
message="获取 AAC Ticket 失败,请检查 AUFE/UAAP 登录状态",
|
||||
)
|
||||
|
||||
# 解密token
|
||||
try:
|
||||
decrypted_token = rsa.decrypt(encrypted_token)
|
||||
if not decrypted_token:
|
||||
raise ValueError("解密后的Ticket为空")
|
||||
except Exception as e:
|
||||
conn.logger.error(f"用户 {conn.userid} 解密 AAC Ticket 失败: {str(e)}")
|
||||
raise ProtectRouterErrorToCode().remote_service_error.to_http_exception(
|
||||
conn.logger.trace_id,
|
||||
message="解密 AAC Ticket 失败",
|
||||
)
|
||||
|
||||
# 保存加密后的token到数据库
|
||||
async with db as session:
|
||||
if is_new:
|
||||
session.add(AACTicket(userid=conn.userid, aac_token=encrypted_token))
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(AACTicket).where(AACTicket.userid == conn.userid)
|
||||
)
|
||||
existing_ticket = result.scalars().first()
|
||||
if existing_ticket:
|
||||
existing_ticket.aac_token = encrypted_token
|
||||
await session.commit()
|
||||
|
||||
conn.logger.success(f"用户 {conn.userid} 成功{action_type}并保存新的 AAC Ticket")
|
||||
# 返回解密后的token
|
||||
return decrypted_token
|
||||
Reference in New Issue
Block a user