⚒️ 重大重构 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,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