commit 46e9e796709b61bdf87fab1f6abbe089c5dacd6f Author: 皓月归尘 Date: Wed Feb 12 02:38:29 2025 +0800 feat: 初始化仓库 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..658b5b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.pyc +*.pyo +*.pyd + +# Virtual environment +venv/ +env/ +.venv/ +.venv3/ +.Python +*.sqlite3 + +data/ + +logs/ + +# IDE-specific files +.idea/ +.vscode/ + +# Compiled source +*.com +*.class +*.dll +*.exe +*.o +*.so + +# Logs and databases +*.log +*.sql +*.sqlite + +# Output files +dist/ +build/ +*.egg-info/ +*.egg + +# OS-specific files +.DS_Store +Thumbs.db + +# Miscellaneous +*.bak +*.swp +*.tmp +*.tmp.* +*.~* + + +# Jupyter Notebook +.ipynb_checkpoints/ +/test/ diff --git a/annotation/__init__.py b/annotation/__init__.py new file mode 100644 index 0000000..6617138 --- /dev/null +++ b/annotation/__init__.py @@ -0,0 +1,7 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/19 00:59 +# @UpdateTime : 2025/01/19 00:59 +# @Author : sonder +# @File : __init__.py.py +# @Software : PyCharm +# @Comment : 本程序 diff --git a/annotation/auth.py b/annotation/auth.py new file mode 100644 index 0000000..c30e316 --- /dev/null +++ b/annotation/auth.py @@ -0,0 +1,42 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/26 16:01 +# @UpdateTime : 2025/01/26 16:01 +# @Author : sonder +# @File : auth.py +# @Software : PyCharm +# @Comment : 本程序为权限装饰器定义 +from functools import wraps + +from fastapi import Request + +from controller.login import LoginController +from exceptions.exception import PermissionException + + +class Auth: + """ + 权限装饰器 + """ + + def __init__(self, permission_list: list): + """ + 权限装饰器 + :param permission_list: 权限列表 + """ + self.permission_list = permission_list + + def __call__(self, func): + @wraps(func) + async def wrapper(request: Request, *args, **kwargs): + # 获取上下文信息 + token = request.headers.get('Authorization') # 直接使用 request 对象 + current_user = await LoginController.get_current_user(request, token) + permissions = current_user.get('permissions') + for permission in set(permissions): + if permission in self.permission_list: + # 如果用户有权限,继续执行接口逻辑 + return await func(request, *args, **kwargs) + # 如果用户没有权限,返回错误信息 + raise PermissionException(message="该用户无此接口权限!") + + return wrapper diff --git a/annotation/log.py b/annotation/log.py new file mode 100644 index 0000000..cec3355 --- /dev/null +++ b/annotation/log.py @@ -0,0 +1,242 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/25 17:32 +# @UpdateTime : 2025/01/25 17:32 +# @Author : sonder +# @File : log.py +# @Software : PyCharm +# @Comment : 本程序日志装饰器定义 + +import json +import time +from datetime import datetime +from functools import wraps, lru_cache +from async_lru import alru_cache +from typing import Optional, Literal +import urllib +import hashlib +import ipaddress + +from fastapi import Request +from fastapi.responses import ORJSONResponse, UJSONResponse, JSONResponse +from user_agents import parse +from httpx import AsyncClient +from config.constant import BusinessType +from config.env import AppConfig, MapConfig +from controller.login import LoginController +from exceptions.exception import LoginException, ServiceWarning, ServiceException, PermissionException +from models import LoginLog, OperationLog, User +from utils.log import logger +from utils.response import Response + + +class Log: + """ + 日志装饰器 + """ + + def __init__( + self, + title: str, + business_type: BusinessType, + log_type: Optional[Literal['login', 'operation']] = 'operation', + ): + """ + 日志装饰器 + + :param title: 当前日志装饰器装饰的模块标题 + :param business_type: 业务类型 + :param log_type: 日志类型(login表示登录日志,operation表示为操作日志) + :return: + """ + self.title = title + self.business_type = business_type.value + self.log_type = log_type + + def __call__(self, func): + @wraps(func) + async def wrapper(request: Request, *args, **kwargs): # 直接接收 request 参数 + start_time = time.time() + # 获取上下文信息 + token = request.headers.get('Authorization') # 直接使用 request 对象 + # 获取请求方法、URL、IP、User-Agent 等信息 + request_method = request.method + # 获取请求路径 + request_path = request.url.path + # 获取请求IP + host = request.headers.get('X-Forwarded-For') or request.client.host + # 获取请求设备类型 + user_agent = request.headers.get('User-Agent', '') + user_agent_info = parse(user_agent) + # 获取请求设备浏览器类型 + browser = f'{user_agent_info.browser.family}' + # 获取请求设备操作系统类型 + system_os = f'{user_agent_info.os.family}' + if user_agent_info.browser.version != (): + browser += f' {user_agent_info.browser.version[0]}' + if user_agent_info.os.version != (): + system_os += f' {user_agent_info.os.version[0]}' + # 解析 IP 地址的地理位置 + location = '内网IP' + if AppConfig.app_ip_location_query: # 假设有一个配置项控制是否查询 IP 地理位置 + location = await get_ip_location(host) + # 获取请求参数 + content_type = request.headers.get('Content-Type') + if content_type and 'application/x-www-form-urlencoded' in content_type: + payload = await request.form() + request_params = '\n'.join([f'{key}: {value}' for key, value in payload.items()]) + elif content_type and 'multipart/form-data' in content_type: + request_params = {} + else: + payload = await request.body() + path_params = request.path_params + request_params = {} + if payload: + request_params.update(json.loads(str(payload, 'utf-8'))) + if path_params: + request_params.update(path_params) + request_params = json.dumps(request_params, ensure_ascii=False) + try: + # 调用原始函数 + result = await func(request, *args, **kwargs) # 将 request 传递给原始函数 + status = 1 # 操作成功 + except (LoginException, ServiceWarning) as e: + logger.warning(e.message) + result = Response.failure(data=e.data, msg=e.message) + status = 0 # 操作失败 + except ServiceException as e: + logger.error(e.message) + result = Response.error(data=e.data, msg=e.message) + status = 0 # 操作失败 + except PermissionException as e: + logger.error(e.message) + result = Response.forbidden(data=e.data, msg=e.message) + status = 0 # 操作失败 + except Exception as e: + logger.exception(e) + result = Response.error(msg=str(e)) + status = 0 # 操作失败 + # 获取操作时间 + cost_time = float(time.time() - start_time) * 100 + # 判断请求是否来自api文档 + request_from_swagger = ( + request.headers.get('referer').endswith('docs') if request.headers.get('referer') else False + ) + request_from_redoc = ( + request.headers.get('referer').endswith('redoc') if request.headers.get('referer') else False + ) + # 根据响应结果的类型使用不同的方法获取响应结果参数 + if ( + isinstance(result, JSONResponse) + or isinstance(result, ORJSONResponse) + or isinstance(result, UJSONResponse) + ): + result_dict = json.loads(str(result.body, 'utf-8')) + else: + if request_from_swagger or request_from_redoc: + result_dict = {} + else: + if result.status_code == 200: + result_dict = {'code': result.status_code, 'message': '获取成功'} + else: + result_dict = {'code': result.status_code, 'message': '获取失败'} + json_result = json.dumps(result_dict, ensure_ascii=False) + + # 根据日志类型向对应的日志表插入数据 + if self.log_type == 'login': + # 登录请求来自于api文档时不记录登录日志,其余情况则记录 + # if request_from_swagger or request_from_redoc: + # pass + # else: + if status == 1: + session_id = request.app.state.session_id + current_user = await User.get_or_none(username=payload.get("username")) + await LoginLog.create( + user_id=current_user.id, + login_ip=host, + login_location=location, + browser=browser, + os=system_os, + status=status, + session_id=session_id + ) + else: + if "image" in request.headers.get("Accept", ""): + pass + else: + current_user = await LoginController.get_current_user(request, token) + await OperationLog.create( + operation_name=self.title, + operation_type=self.business_type, + request_method=request_method, + request_path=request_path, + operator_id=current_user.get("id"), + department_id=current_user.get("department_id"), + department_name=current_user.get("department_name"), + host=host, + location=location, + user_agent=user_agent, + browser=browser, + os=system_os, + request_params=request_params, + response_result=json_result, + status=status, + cost_time=cost_time, + ) + + # 返回原始函数的结果 + return result + + return wrapper + + +@alru_cache() +async def get_ip_location(ip: str) -> str: + """ + 根据IP地址获取地理位置 + """ + try: + ip_obj = ipaddress.ip_address(ip) + if ip_obj.is_private: + return "内网IP" + else: + # 服务地址 + host = "https://api.map.baidu.com" + headers = { + 'User-Agent': 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.42'} + # 接口地址 + uri = "/location/ip" + params = { + "ip": ip, + "coor": "bd09ll", + "ak": MapConfig.ak, + } + paramsArr = [] + for key in params: + paramsArr.append(key + "=" + params[key]) + + queryStr = uri + "?" + "&".join(paramsArr) + + # 对queryStr进行转码,safe内的保留字符不转换 + encodedStr = urllib.request.quote(queryStr, safe="/:=&?#+!$,;'@()*[]") + # 在最后直接追加上您的SK + rawStr = encodedStr + MapConfig.sk + + # 计算sn + sn = hashlib.md5(urllib.parse.quote_plus(rawStr).encode("utf8")).hexdigest() + # 将sn参数添加到请求中 + queryStr = queryStr + "&sn=" + sn + url = host + queryStr + async with AsyncClient(headers=headers,timeout=60) as client: + response = await client.get(url) + if response.status_code == 200: + result = response.json() + if result.get("status") == 0: + return result.get("content", {}).get("address", "未知地点") + else: + return "未知地点" + else: + return "未知地点" + except ValueError: + # 如果IP地址格式无效 + return "未知地点" + diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..44f1f38 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,7 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/19 00:55 +# @UpdateTime : 2025/01/19 00:55 +# @Author : sonder +# @File : __init__.py.py +# @Software : PyCharm +# @Comment : 本程序 diff --git a/api/cache.py b/api/cache.py new file mode 100644 index 0000000..f220b79 --- /dev/null +++ b/api/cache.py @@ -0,0 +1,116 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/02/04 15:18 +# @UpdateTime : 2025/02/04 15:18 +# @Author : sonder +# @File : cache.py +# @Software : PyCharm +# @Comment : 本程序 + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import JSONResponse + +from annotation.log import Log +from config.constant import BusinessType, RedisKeyConfig +from controller.login import LoginController +from schemas.cache import CacheMonitor, GetCacheInfoResponse, CacheInfo, GetCacheKeysListResponse, \ + GetCacheMonitorResponse +from schemas.common import BaseResponse +from utils.response import Response + +cacheAPI = APIRouter( + prefix="/cache", + dependencies=[Depends(LoginController.get_current_user)], +) + + +@cacheAPI.get("/monitor", response_class=JSONResponse, response_model=GetCacheMonitorResponse, + summary="获取缓存监控信息") +@Log(title="获取缓存监控信息", business_type=BusinessType.SELECT) +async def get_cache_info(request: Request): + info = await request.app.state.redis.info() + db_size = await request.app.state.redis.dbsize() + command_stats_dict = await request.app.state.redis.info('commandstats') + command_stats = [ + dict(name=key.split('_')[1], value=str(value.get('calls'))) for key, value in command_stats_dict.items() + ] + cache_info = CacheMonitor( + info=info, + dbSize=db_size, + commandStats=command_stats + + ) + return Response.success(data=cache_info) + + +@cacheAPI.get("/names", response_class=JSONResponse, response_model=GetCacheInfoResponse, + summary="获取缓存名称列表") +@Log(title="获取缓存名称列表", business_type=BusinessType.SELECT) +async def get_cache_names(request: Request): + name_list = [] + for key_config in RedisKeyConfig: + name_list.append( + CacheInfo( + cacheKey='', + cacheName=key_config.key, + cacheValue='', + remark=key_config.remark, + ) + ) + return Response.success(data=name_list) + + +@cacheAPI.get("/keys/{cacheName}", response_class=JSONResponse, response_model=GetCacheKeysListResponse, + summary="获取缓存键名列表") +@Log(title="获取缓存键名列表", business_type=BusinessType.SELECT) +async def get_cache_keys(request: Request, cacheName: str = Path(description="缓存名称")): + cache_keys = await request.app.state.redis.keys(f'{cacheName}*') + cache_key_list = [key.split(':', 1)[1] for key in cache_keys if key.startswith(f'{cacheName}:')] + return Response.success(data=cache_key_list) + + +@cacheAPI.get("/info/{cacheName}/{cacheKey}", response_class=JSONResponse, response_model=GetCacheInfoResponse, + summary="获取缓存信息") +@Log(title="获取缓存信息", business_type=BusinessType.SELECT) +async def get_cache_info(request: Request, cacheName: str = Path(description="缓存名称"), + cacheKey: str = Path(description="缓存键名")): + cache_value = await request.app.state.redis.get(f'{cacheName}:{cacheKey}') + cache_info = CacheInfo( + cacheKey=cacheKey, + cacheName=cacheName, + cacheValue=cache_value, + remark="", + ) + return Response.success(data=cache_info) + + +@cacheAPI.delete("/cacheName/{name}", response_class=JSONResponse, response_model=BaseResponse, + summary="通过键名删除缓存") +@cacheAPI.post("/cacheName/{name}", response_class=JSONResponse, response_model=BaseResponse, + summary="通过键名删除缓存") +@Log(title="通过键名删除缓存", business_type=BusinessType.DELETE) +async def delete_cache(request: Request, name: str = Path(description="缓存名称")): + cache_keys = await request.app.state.redis.keys(f'{name}*') + if cache_keys: + await request.app.state.redis.delete(*cache_keys) + return Response.success(msg=f"删除{name}缓存成功!") + + +@cacheAPI.delete("/cacheKey/{key}", response_class=JSONResponse, response_model=BaseResponse, + summary="通过键值删除缓存") +@cacheAPI.post("/cacheKey/{key}", response_class=JSONResponse, response_model=BaseResponse, summary="通过键值删除缓存") +@Log(title="通过键值删除缓存", business_type=BusinessType.DELETE) +async def delete_cache_key(request: Request, key: str = Path(description="缓存键名")): + cache_keys = await request.app.state.redis.keys(f'*{key}') + if cache_keys: + await request.app.state.redis.delete(*cache_keys) + return Response.success(msg=f"删除{key}缓存成功!") + + +@cacheAPI.delete("/clearAll", response_class=JSONResponse, response_model=BaseResponse, summary="删除所有缓存") +@cacheAPI.post("/clearAll", response_class=JSONResponse, response_model=BaseResponse, summary="删除所有缓存") +@Log(title="删除所有缓存", business_type=BusinessType.DELETE) +async def delete_all_cache(request: Request): + cache_keys = await request.app.state.redis.keys() + if cache_keys: + await request.app.state.redis.delete(*cache_keys) + return Response.success(msg="删除所有缓存成功!") diff --git a/api/department.py b/api/department.py new file mode 100644 index 0000000..58f1607 --- /dev/null +++ b/api/department.py @@ -0,0 +1,390 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/20 01:30 +# @UpdateTime : 2025/01/20 01:30 +# @Author : sonder +# @File : department.py +# @Software : PyCharm +# @Comment : 本程序 +from typing import Optional + +from fastapi import APIRouter, Depends, Query, Path, Request +from fastapi.responses import JSONResponse + +from annotation.log import Log +from config.constant import BusinessType +from controller.login import LoginController +from models import Department, Role, DepartmentRole +from schemas.common import BaseResponse +from schemas.department import AddDepartmentParams, GetDepartmentInfoResponse, \ + GetDepartmentListResponse, AddDepartmentRoleParams, GetDepartmentRoleInfoResponse, DeleteDepartmentListParams +from utils.response import Response + +departmentAPI = APIRouter(prefix="/department", dependencies=[Depends(LoginController.get_current_user)]) + + +@departmentAPI.post("/add", response_model=BaseResponse, response_class=JSONResponse, summary="新增部门") +@Log(title="新增部门", business_type=BusinessType.INSERT) +async def add_department(request: Request, params: AddDepartmentParams, + current_user: dict = Depends(LoginController.get_current_user)): + parent_id = current_user.get("department_id") + if not params.parent_id: + params.parent_id = parent_id + department = await Department.create( + name=params.name, + parent_id=params.parent_id, + principal=params.principal, + phone=params.phone, + email=params.email, + remark=params.remark, + sort=params.sort, + status=params.status + ) + if department: + return Response.success(msg="添加成功!") + else: + return Response.error(msg="添加失败!") + + +@departmentAPI.delete("/delete/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="删除部门") +@departmentAPI.post("/delete/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="删除部门") +@Log(title="删除部门", business_type=BusinessType.DELETE) +async def delete_department(request: Request, id: str = Path(description="部门ID")): + if department := await Department.get_or_none(id=id, del_flag=1): + if await delete_department_recursive(department_id=department.id): + return Response.success(msg="删除成功!") + return Response.error(msg="删除失败!") + else: + return Response.error(msg="删除失败,部门不存在!") + + +@departmentAPI.delete("/deleteList", response_model=BaseResponse, response_class=JSONResponse, summary="批量删除部门") +@departmentAPI.post("/deleteList", response_model=BaseResponse, response_class=JSONResponse, summary="批量删除部门") +@Log(title="批量删除部门", business_type=BusinessType.DELETE) +async def delete_department_list(request: Request, params: DeleteDepartmentListParams): + for item in set(params.ids): + if department := await Department.get_or_none(id=item, del_flag=1): + await delete_department_recursive(department_id=department.id) + return Response.success(msg="删除成功!") + + +async def delete_department_recursive(department_id: str): + """ + 递归删除部门及其附属部门 + :param department_id: 部门ID + :return: + """ + await Department.filter(id=department_id).delete() + sub_departments = await Department.filter(parentId=department_id).all() + for sub_department in sub_departments: + await delete_department_recursive(sub_department.id) + return True + + +@departmentAPI.put("/update/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="修改部门") +@departmentAPI.post("/update/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="修改部门") +@Log(title="修改部门", business_type=BusinessType.UPDATE) +async def update_department(request: Request, params: AddDepartmentParams, id: str = Path(description="部门ID")): + if department := await Department.get_or_none(id=id, del_flag=1): + department.name = params.name + department.parent_id = params.parent_id + department.principal = params.principal + department.phone = params.phone + department.email = params.email + department.remark = params.remark + department.sort = params.sort + department.status = params.status + await department.save() + return Response.success(msg="修改成功!") + else: + return Response.error(msg="修改失败,部门不存在!") + + +@departmentAPI.get("/info/{id}", response_model=GetDepartmentInfoResponse, response_class=JSONResponse, + summary="查询部门详情") +@Log(title="查询部门详情", business_type=BusinessType.SELECT) +async def get_department(request: Request, id: str = Path(description="部门ID")): + if department := await Department.get_or_none(id=id, del_flag=1).values( + id="id", + name="name", + parent_id="parent_id", + principal="principal", + phone="phone", + email="email", + remark="remark", + sort="sort", + status="status", + create_time="create_time", + update_time="update_time", + create_by="create_by", + update_by="update_by" + ): + return Response.success(data=department) + else: + return Response.error(msg="部门不存在!") + + +@departmentAPI.get("/list", response_model=GetDepartmentListResponse, response_class=JSONResponse, + summary="查询部门列表") +@Log(title="查询部门列表", business_type=BusinessType.SELECT) +async def get_department_list( + request: Request, + page: int = Query(default=1, description="当前页码"), + pageSize: int = Query(default=10, description="每页条数"), + name: Optional[str] = Query(default=None, description="部门名称"), + principal: Optional[str] = Query(default=None, description="负责人"), + phone: Optional[str] = Query(default=None, description="电话"), + email: Optional[str] = Query(default=None, description="邮箱"), + remark: Optional[str] = Query(default=None, description="备注"), + sort: Optional[int] = Query(default=None, description="排序权重"), + current_user: dict = Depends(LoginController.get_current_user) +): + filterArgs = { + f'{k}__contains': v for k, v in { + 'name': name, + 'principal': principal, + 'phone': phone, + 'email': email, + 'remark': remark, + 'sort': sort + }.items() if v + } + department_id = current_user.get("department_id", "") + # 递归查询所有部门 + all_departments = await get_department_and_subdepartments(department_id, filterArgs) + + # 分页处理 + total = len(all_departments) + paginated_departments = all_departments[(page - 1) * pageSize: page * pageSize] + return Response.success(data={ + "result": paginated_departments, + "total": total, + "page": page + }) + + +async def get_department_and_subdepartments(department_id: str, filterArgs: dict, visited: set = None): + """ + 查询当前部门及其所有下属部门的数据,并根据 id 去重。 + + :param department_id: 当前部门 ID + :param filterArgs: 过滤条件 + :param visited: 已访问的部门 ID 集合,用于避免循环依赖 + :return: 去重后的部门列表 + """ + if visited is None: + visited = set() # 初始化已访问的部门 ID 集合 + + # 如果当前部门 ID 已经访问过,直接返回空列表,避免死循环 + if department_id in visited: + return [] + + visited.add(department_id) # 标记当前部门 ID 为已访问 + + # 查询当前部门 + current_department = await Department.filter( + id=department_id, + **filterArgs + ).values( + id="id", + name="name", + parent_id="parent_id", + principal="principal", + phone="phone", + email="email", + remark="remark", + sort="sort", + status="status", + create_time="create_time", + update_time="update_time", + create_by="create_by", + update_by="update_by" + ) + + # 查询直接子部门 + sub_departments = await Department.filter( + parent_id=department_id, # 只根据 parent_id 查询 + **filterArgs + ).values( + id="id", + name="name", + parent_id="parent_id", + principal="principal", + phone="phone", + email="email", + remark="remark", + sort="sort", + status="status", + create_time="create_time", + update_time="update_time", + create_by="create_by", + update_by="update_by" + ) + + # 递归查询子部门的子部门 + for department in sub_departments[:]: # 使用切片复制避免修改迭代中的列表 + sub_sub_departments = await get_department_and_subdepartments(department["id"], filterArgs, visited) + sub_departments.extend(sub_sub_departments) + + # 合并当前部门和所有下属部门的数据 + all_departments = current_department + sub_departments + + # 根据 id 去重 + unique_departments = [] + seen_ids = set() # 用于记录已经处理过的部门 ID + for department in all_departments: + if department["id"] not in seen_ids: + unique_departments.append(department) + seen_ids.add(department["id"]) + + return unique_departments + + +@departmentAPI.post("/addRole", response_model=BaseResponse, response_class=JSONResponse, summary="添加部门角色") +@Log(title="添加部门角色", business_type=BusinessType.INSERT) +async def add_department_role(request: Request, params: AddDepartmentRoleParams): + if await DepartmentRole.get_or_none(department_id=params.department_id, role_id=params.role_id, del_flag=1): + return Response.error(msg="该部门已存在该角色!") + if department := await Department.get_or_none(id=params.department_id, del_flag=1): + if role := await Role.get_or_none(id=params.role_id, del_flag=1): + departmentRole = await DepartmentRole.create(department_id=department.id, role_id=role.id) + if departmentRole: + return Response.success(msg="添加成功!") + else: + return Response.error(msg="添加失败!") + else: + return Response.error(msg="添加失败,角色不存在!") + else: + return Response.error(msg="添加失败,部门不存在!") + + +@departmentAPI.delete("/deleteRole/{id}", response_model=BaseResponse, response_class=JSONResponse, + summary="删除部门角色") +@departmentAPI.post("/deleteRole/{id}", response_model=BaseResponse, response_class=JSONResponse, + summary="删除部门角色") +@Log(title="删除部门角色", business_type=BusinessType.DELETE) +async def delete_department_role(request: Request, id: str = Path(description="部门角色ID")): + if departmentRole := await DepartmentRole.get_or_none(id=id, del_flag=1): + await departmentRole.delete() + return Response.success(msg="删除成功!") + else: + return Response.error(msg="删除失败,部门角色不存在!") + + +@departmentAPI.put("/updateRole/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="修改部门角色") +@departmentAPI.post("/updateRole/{id}", response_model=BaseResponse, response_class=JSONResponse, + summary="修改部门角色") +@Log(title="修改部门角色", business_type=BusinessType.UPDATE) +async def update_department_role(request: Request, params: AddDepartmentRoleParams, + id: str = Path(description="部门角色ID")): + if departmentRole := await DepartmentRole.get_or_none(id=id, del_flag=1): + if department := await Department.get_or_none(id=params.department_id, del_flag=1): + if role := await Role.get_or_none(id=params.role_id, del_flag=1): + departmentRole.department_id = department.id + departmentRole.role_id = role.id + await departmentRole.save() + return Response.success(msg="修改成功!") + else: + return Response.error(msg="修改失败,角色不存在!") + else: + return Response.error(msg="修改失败,部门不存在!") + else: + return Response.error(msg="修改失败,部门角色不存在!") + + +@departmentAPI.get("/roleInfo", response_model=GetDepartmentRoleInfoResponse, response_class=JSONResponse, + summary="获取部门角色信息") +@Log(title="获取部门角色信息", business_type=BusinessType.SELECT) +async def get_department_role_info(request: Request, id: str = Query(description="部门角色ID")): + if departmentRole := await DepartmentRole.get_or_none(id=id, del_flag=1): + data = await departmentRole.first().values( + id="id", + department_id="department__id", + department_name="department__name", + department_phone="department__phone", + department_principal="department__principal", + department_email="department__email", + role_name="role__name", + role_code="role__code", + role_id="role__id", + create_time="create_time", + update_time="update_time" + ) + return Response.success(data=data) + else: + return Response.error(msg="获取失败,部门角色不存在!") + + +@departmentAPI.get("/roleList", response_model=GetDepartmentListResponse, response_class=JSONResponse, + summary="获取部门角色列表") +@Log(title="获取部门角色列表", business_type=BusinessType.SELECT) +async def get_department_role_list( + request: Request, + page: int = Query(default=1, description="当前页码"), + pageSize: int = Query(default=10, description="每页条数"), + department_id: Optional[str] = Query(default=None, description="部门ID"), + department_name: Optional[str] = Query(default=None, description="部门名称"), + department_phone: Optional[str] = Query(default=None, description="部门电话"), + department_principal: Optional[str] = Query(default=None, description="部门负责人"), + department_email: Optional[str] = Query(default=None, description="部门邮箱"), + role_id: Optional[str] = Query(default=None, description="角色ID"), + role_name: Optional[str] = Query(default=None, description="角色名称"), + role_code: Optional[str] = Query(default=None, description="角色编码"), +): + filterArgs = { + f'{k}__contains': v for k, v in { + 'department__id': department_id, + 'department__name': department_name, + 'department__phone': department_phone, + 'department__principal': department_principal, + 'department__email': department_email, + 'role__id': role_id, + 'role__name': role_name, + 'role__code': role_code + }.items() if v + } + total = await DepartmentRole.filter(**filterArgs).count() + data = await DepartmentRole.filter(**filterArgs).offset((page - 1) * pageSize).limit(pageSize).values( + id="id", + department_id="department__id", + department_name="department__name", + department_phone="department__phone", + department_principal="department__principal", + department_email="department__email", + role_name="role__name", + role_code="role__code", + role_id="role__id", + create_time="create_time", + update_time="update_time" + ) + return Response.success(data={ + "result": data, + "total": total, + "page": page + }) + + +@departmentAPI.get("/roleList/{id}", response_model=GetDepartmentListResponse, response_class=JSONResponse, + summary="用户获取部门角色列表") +@Log(title="获取部门角色列表", business_type=BusinessType.OTHER) +async def get_department_role_list( + request: Request, + id: str = Path(..., description="部门ID") +): + data = await Role.filter(department__id=id).values( + id="id", + department_id="department__id", + department_name="department__name", + department_phone="department__phone", + department_principal="department__principal", + department_email="department__email", + role_name="name", + role_code="code", + role_id="id", + create_time="create_time", + update_time="update_time" + ) + return Response.success(data={ + "result": data, + "total": len(data), + "page": 1 + }) diff --git a/api/file.py b/api/file.py new file mode 100644 index 0000000..07d2dac --- /dev/null +++ b/api/file.py @@ -0,0 +1,198 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/25 22:16 +# @UpdateTime : 2025/01/25 22:16 +# @Author : sonder +# @File : file.py +# @Software : PyCharm +# @Comment : 本程序 +import os +from datetime import datetime + +from fastapi import APIRouter, UploadFile, File, Path, Depends, Request, Query +from fastapi.responses import FileResponse, JSONResponse + +from annotation.log import Log +from config.constant import BusinessType +from config.env import UploadConfig +from controller.login import LoginController +from exceptions.exception import ModelValidatorException, ServiceException +from models import File as FileModel +from schemas.common import BaseResponse +from schemas.file import UploadFileResponse, GetFileInfoResponse, GetFileListResponse +from utils.response import Response +from utils.upload import Upload + +fileAPI = APIRouter( + prefix="/file", +) + + +@fileAPI.post("/upload", response_model=UploadFileResponse, response_class=JSONResponse, summary="上传文件") +@Log(title="上传文件", business_type=BusinessType.INSERT) +async def upload_file( + request: Request, + file: UploadFile = File(..., description="上传的文件"), + current_user: dict = Depends(LoginController.get_current_user), +): + # 1. 检查文件扩展名是否允许 + file_extension = os.path.splitext(file.filename)[1][1:].lower() # 获取文件扩展名并转换为小写 + if file_extension not in UploadConfig.DEFAULT_ALLOWED_EXTENSION: + raise ModelValidatorException(message="文件类型不支持") + + # 2. 生成唯一的文件名 + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + unique_filename = f"{current_user.get('id')}_{timestamp}.{file_extension}" + + # 3. 保存文件到服务器 + file_path = os.path.join(UploadConfig.UPLOAD_PATH, unique_filename) + with open(file_path, "wb") as buffer: + buffer.write(await file.read()) + + # 4. 构建文件的相对路径和绝对路径 + relative_path = os.path.join(UploadConfig.UPLOAD_PREFIX, unique_filename) # 相对路径 + absolute_path = os.path.abspath(file_path) # 绝对路径 + + # 5. 将文件信息保存到数据库 + file_record = await FileModel.create( + name=file.filename, + size=os.path.getsize(file_path), + file_type=file.content_type, + absolute_path=absolute_path, + relative_path=relative_path, + uploader=current_user.get("id"), + ) + result = await file_record.first().values( + id="id", + name="name", + size="size", + file_type="file_type", + relative_path="relative_path", + absolute_path="absolute_path", + uploader_id="uploader__id", + uploader_username="uploader__username", + uploader_nickname="uploader__nickname", + uploader_department_id="uploader__department__id", + uploader_department_name="uploader__department__name", + create_time="create_time", + update_time="update_time", + ) + return Response.success(data=result) + + +@fileAPI.get("/{id}", summary="下载文件") +@Log(title="获取文件", business_type=BusinessType.SELECT) +async def download_file( + request: Request, + id: str = Path(..., description="文件ID"), +): + # 1. 查询文件记录 + file_record = await FileModel.get_or_none(id=id) + if not file_record: + raise ServiceException(message="文件不存在!") + + # 2. 检查文件是否存在 + if not os.path.exists(file_record.absolute_path): + raise ServiceException(message="文件不存在!") + + # 3. 返回文件内容 + return FileResponse( + path=file_record.absolute_path, + filename=file_record.name, + media_type=file_record.file_type, + ) + + +@fileAPI.get("/info/{id}", response_class=JSONResponse, response_model=GetFileInfoResponse, summary="获取文件信息") +@Log(title="获取文件信息", business_type=BusinessType.SELECT) +async def get_file_info( + request: Request, + id: str = Path(..., description="文件ID"), + current_user: dict = Depends(LoginController.get_current_user), +): + # 1. 查询文件记录 + file_record = await FileModel.get_or_none(id=id) + if not file_record: + raise ServiceException(message="文件不存在!") + result = await file_record.first().values( + id="id", + name="name", + size="size", + file_type="file_type", + relative_path="relative_path", + absolute_path="absolute_path", + uploader_id="uploader__id", + uploader_username="uploader__username", + uploader_nickname="uploader__nickname", + uploader_department_id="uploader__department__id", + uploader_department_name="uploader__department__name", + create_time="create_time", + update_time="update_time", + ) + return Response.success(data=result) + + +@fileAPI.delete("/delete/{id}", response_class=JSONResponse, response_model=BaseResponse, summary="删除文件") +@fileAPI.post("/delete/{id}", response_class=JSONResponse, response_model=BaseResponse, summary="删除文件") +@Log(title="删除文件", business_type=BusinessType.DELETE) +async def delete_file( + request: Request, + id: str = Path(..., description="文件ID"), +current_user: dict = Depends(LoginController.get_current_user),): + # 1. 查询文件记录 + file_record = await FileModel.get_or_none(id=id) + if not file_record: + raise ServiceException(message="文件不存在!") + if await Upload.check_file_exists(file_record.absolute_path): + await Upload.delete_file(file_record.absolute_path) + await file_record.delete() + return Response.success() + + +@fileAPI.get("/list", response_class=JSONResponse, response_model=GetFileListResponse, summary="获取文件列表") +@Log(title="获取文件列表", business_type=BusinessType.SELECT) +async def get_file_list( + request: Request, + page: int = Query(default=1, description="页码"), + pageSize: int = Query(default=10, description="每页数量"), + name: str = Query(default=None, description="文件名"), + file_type: str = Query(default=None, description="文件类型"), + uploader_id: str = Query(default=None, description="上传者ID"), + uploader_username: str = Query(default=None, description="上传者用户名"), + uploader_nickname: str = Query(default=None, description="上传者昵称"), + department_id: str = Query(default=None, description="上传者部门ID"), + department_name: str = Query(default=None, description="上传者部门名称"), +current_user: dict = Depends(LoginController.get_current_user),): + # 1. 查询文件记录 + filterArgs = { + f'{k}__contains': v for k, v in { + 'name': name, + 'file_type': file_type, + 'uploader__id': uploader_id, + 'uploader__username': uploader_username, + 'uploader__nickname': uploader_nickname, + 'uploader__department__id': department_id, + 'uploader__department__name': department_name + }.items() if v + } + total = await FileModel.filter(**filterArgs).count() + result = await FileModel.filter(**filterArgs).order_by('-create_time').offset((page - 1) * pageSize).limit( + pageSize).values( + id="id", + name="name", + size="size", + file_type="file_type", + relative_path="relative_path", + absolute_path="absolute_path", + uploader_id="uploader__id", + uploader_username="uploader__username", + uploader_nickname="uploader__nickname", + uploader_department_id="uploader__department__id", + uploader_department_name="uploader__department__name", + create_time="create_time", + update_time="update_time", + ) + return Response.success(data={ + "total": total, + "result": result, + "page": page, + }) diff --git a/api/i18n.py b/api/i18n.py new file mode 100644 index 0000000..e01e5a7 --- /dev/null +++ b/api/i18n.py @@ -0,0 +1,266 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/02/04 16:05 +# @UpdateTime : 2025/02/04 16:05 +# @Author : sonder +# @File : i18n.py +# @Software : PyCharm +# @Comment : 本程序 +from datetime import timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, Path, Request, Query +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse + +from annotation.log import Log +from config.constant import BusinessType, RedisKeyConfig +from controller.login import LoginController +from schemas.common import BaseResponse +from schemas.i18n import AddLocaleParams, GetLocaleInfoResponse, AddI18nParams, GetI18nInfoResponse, \ + GetI18nInfoListResponse, GetI18nListResponse +from utils.response import Response +from models import I18n, Locale + +i18nAPI = APIRouter( + prefix="/i18n", +) + + +@i18nAPI.post("/addLocale", response_class=JSONResponse, response_model=BaseResponse, summary="添加国际化类型") +@Log(title="添加国际化类型", business_type=BusinessType.INSERT) +async def add_locale(request: Request, params: AddLocaleParams, current_user=Depends(LoginController.get_current_user)): + if await Locale.get_or_none(code=params.code): + return Response.error(msg="该语言代码已存在!") + locale = await Locale.create( + code=params.code, + name=params.name + ) + if locale: + return Response.success(msg="添加成功!") + else: + return Response.error(msg="添加失败!") + + +@i18nAPI.delete("/deleteLocale/{id}", response_class=JSONResponse, response_model=BaseResponse, + summary="删除国际化类型") +@i18nAPI.post("/deleteLocale/{id}", response_class=JSONResponse, response_model=BaseResponse, + summary="删除国际化类型") +@Log(title="删除国际化类型", business_type=BusinessType.DELETE) +async def delete_locale(request: Request, id: str = Path(description="国际化类型ID"), + current_user=Depends(LoginController.get_current_user)): + if locale := await Locale.get_or_none(id=id): + # 移除语言 + await I18n.filter(locale_id=locale.id).delete() + await locale.delete() + return Response.success(msg="删除成功!") + else: + return Response.error(msg="该国际化类型不存在!") + + +@i18nAPI.put("/updateLocale/{id}", response_class=JSONResponse, response_model=BaseResponse, summary="修改国际化类型") +@i18nAPI.post("/updateLocale/{id}", response_class=JSONResponse, response_model=BaseResponse, summary="修改国际化类型") +@Log(title="修改国际化类型", business_type=BusinessType.UPDATE) +async def update_locale(request: Request, params: AddLocaleParams, id: str = Path(description="国际化类型ID")): + if locale := await Locale.get_or_none(id=id): + if await Locale.get_or_none(code=params.code, name=params.name, id=id): + return Response.error(msg="该国际化类型已存在!") + locale.code = params.code + locale.name = params.name + await locale.save() + return Response.success(msg="修改成功!") + else: + return Response.error(msg="该国际化类型不存在!") + + +@i18nAPI.get("/locale/info/{id}", response_class=JSONResponse, response_model=GetLocaleInfoResponse, + summary="获取国际化类型信息") +@Log(title="获取国际化类型信息", business_type=BusinessType.SELECT) +async def get_locale_info(request: Request, id: str = Path(description="国际化类型ID")): + if locale := await Locale.get_or_none(id=id): + locale = { + "id": locale.id, + "code": locale.code, + "name": locale.name, + "create_time": locale.create_time, + "update_time": locale.update_time, + "create_by": locale.create_by, + "update_by": locale.update_by, + } + return Response.success(data=locale) + else: + return Response.error(msg="该国际化类型不存在!") + + +@i18nAPI.get("/locale/list", response_class=JSONResponse, response_model=BaseResponse, summary="获取国际化类型列表") +# @Log(title="获取国际化类型列表", business_type=BusinessType.SELECT) +async def get_locale_list(request: Request, + page: int = Query(default=1, description="当前页码"), + pageSize: int = Query(default=50, description="每页条数"), + name: Optional[str] = Query(default=None, description="国际化类型名称"), + code: Optional[str] = Query(default=None, description="国际化类型代码"), + ): + filterArgs = { + f'{k}__contains': v for k, v in { + 'name': name, + 'code': code + }.items() if v + } + total = await Locale.filter(**filterArgs).count() + data = await Locale.filter(**filterArgs).offset((page - 1) * pageSize).limit(pageSize).distinct().values( + id="id", + code="code", + name="name", + create_time="create_time", + update_time="update_time", + create_by="create_by", + update_by="update_by" + ) + + return Response.success(data={ + "total": total, + "result": data, + "page": page + }) + + +@i18nAPI.post("/addI18n", response_class=JSONResponse, response_model=BaseResponse, summary="添加国际化内容") +@Log(title="添加国际化内容", business_type=BusinessType.INSERT) +async def add_i18n(request: Request, params: AddI18nParams, current_user=Depends(LoginController.get_current_user)): + if await I18n.get_or_none(key=params.key, locale_id=params.locale_id): + return Response.error(msg="该国际化内容已存在!") + locale = await Locale.get_or_none(id=params.locale_id) + i18n = await I18n.create( + key=params.key, + translation=params.translation, + locale=locale + ) + if i18n: + return Response.success(msg="添加成功!") + else: + return Response.error(msg="添加失败!") + + +@i18nAPI.delete("/deleteI18n/{id}", response_class=JSONResponse, response_model=BaseResponse, + summary="删除国际化内容") +@i18nAPI.post("/deleteI18n/{id}", response_class=JSONResponse, response_model=BaseResponse, + summary="删除国际化内容") +@Log(title="删除国际化内容", business_type=BusinessType.DELETE) +async def delete_i18n(request: Request, id: str = Path(description="国际化内容ID"), + current_user=Depends(LoginController.get_current_user)): + if i18n := await I18n.get_or_none(id=id): + await i18n.delete() + return Response.success(msg="删除成功!") + else: + return Response.error(msg="该国际化内容不存在!") + + +@i18nAPI.put("/updateI18n/{id}", response_class=JSONResponse, response_model=BaseResponse, summary="修改国际化内容") +@i18nAPI.post("/updateI18n/{id}", response_class=JSONResponse, response_model=BaseResponse, summary="修改国际化内容") +@Log(title="修改国际化内容", business_type=BusinessType.UPDATE) +async def update_i18n(request: Request, params: AddI18nParams, id: str = Path(description="国际化内容ID"), + current_user=Depends(LoginController.get_current_user)): + if i18n := await I18n.get_or_none(id=id): + locale = await Locale.get_or_none(id=params.locale_id) + i18n.key = params.key + i18n.translation = params.translation + i18n.locale = locale + await i18n.save() + return Response.success(msg="修改成功!") + else: + return Response.error(msg="该国际化内容不存在!") + + +@i18nAPI.get("/info/{id}", response_class=JSONResponse, response_model=GetI18nInfoResponse, + summary="获取国际化内容信息") +@Log(title="获取国际化内容信息", business_type=BusinessType.SELECT) +async def get_i18n_info(request: Request, id: str = Path(description="国际化内容ID")): + if i18n := await I18n.get_or_none(id=id): + i18n = { + "id": i18n.id, + "key": i18n.key, + "translation": i18n.translation, + "locale_id": i18n.locale.id, + "locale_name": i18n.locale.name, + "create_time": i18n.create_time, + "update_time": i18n.update_time, + "create_by": i18n.create_by, + "update_by": i18n.update_by, + } + return Response.success(data=i18n) + else: + return Response.error(msg="该国际化内容不存在!") + + +@i18nAPI.get("/list", response_class=JSONResponse, response_model=GetI18nListResponse, summary="获取国际化内容列表") +@Log(title="获取国际化内容列表", business_type=BusinessType.SELECT) +async def get_i18n_list(request: Request, + page: int = Query(default=1, description="当前页码"), + pageSize: int = Query(default=50, description="每页条数"), + key: Optional[str] = Query(default=None, description="国际化内容key"), + locale_id: Optional[str] = Query(default=None, description="国际化内容语言ID"), + translation: Optional[str] = Query(default=None, description="国际化内容翻译内容"), + ): + filterArgs = { + f'{k}__contains': v for k, v in { + 'key': key, + 'locale_id': locale_id, + 'translation': translation + }.items() if v + } + total = await I18n.filter(**filterArgs).count() + data = await I18n.filter(**filterArgs).offset((page - 1) * pageSize).limit(pageSize).values( + id="id", + key="key", + translation="translation", + locale_id="locale__id", + locale_code="locale__code", + locale_name="locale__name", + create_time="create_time", + update_time="update_time", + create_by="create_by", + update_by="update_by" + ) + return Response.success(data={ + "total": total, + "result": data, + "page": page + }) + + +@i18nAPI.get("/infoList/{id}", response_class=JSONResponse, response_model=GetI18nInfoListResponse, + summary="获取国际化列表") +# @Log(title="获取国际化列表", business_type=BusinessType.SELECT) +async def get_i18n_info_list(request: Request, id: str = Path(description="国际化内容语言ID")): + if locale := await Locale.get_or_none(id=id): + result = await request.app.state.redis.get(f'{RedisKeyConfig.TRANSLATION_INFO.key}:{id}') + if result: + result = eval(result) + return Response.success(data=result) + data = await I18n.filter(locale_id=locale.id).values( + id="id", + key="key", + translation="translation", + locale_id="locale__id", + locale_name="locale__name", + create_time="create_time", + update_time="update_time", + create_by="create_by", + update_by="update_by" + ) + result = {} + for i18n in data: + result[f"{i18n['key']}"] = i18n["translation"] + await request.app.state.redis.set(f'{RedisKeyConfig.TRANSLATION_INFO.key}:{id}', + str(jsonable_encoder({ + "data": result, + "locale": locale.code, + "name": locale.name, + })), + ex=timedelta(minutes=60)) + return Response.success(data={ + "data": result, + "locale": locale.code, + "name": locale.name, + }) + return Response.error(msg="该国际化内容语言不存在!") + diff --git a/api/log.py b/api/log.py new file mode 100644 index 0000000..24343ce --- /dev/null +++ b/api/log.py @@ -0,0 +1,183 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/27 21:40 +# @UpdateTime : 2025/01/27 21:40 +# @Author : sonder +# @File : log.py +# @Software : PyCharm +# @Comment : 本程序 + +from fastapi import APIRouter, Depends, Path, Query, Request +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse + +from annotation.auth import Auth +from annotation.log import Log +from config.constant import BusinessType, RedisKeyConfig +from controller.login import LoginController +from models import LoginLog, OperationLog +from schemas.common import BaseResponse, DeleteListParams +from schemas.log import GetLoginLogResponse, GetOperationLogResponse +from utils.response import Response + +logAPI = APIRouter( + prefix="/log", + dependencies=[Depends(LoginController.get_current_user)] +) + + +@logAPI.get("/login", response_class=JSONResponse, response_model=GetLoginLogResponse, summary="用户获取登录日志") +async def get_login_log(request: Request, + page: int = Query(default=1, description="页码"), + pageSize: int = Query(default=10, description="每页数量"), + current_user: dict = Depends(LoginController.get_current_user), + ): + online_user_list = await LoginController.get_online_user(request) + online_user_list = list( + filter(lambda x: x["user_id"] == current_user.get("id"), jsonable_encoder(online_user_list, ))) + user_id = current_user.get("id") + result = await LoginLog.filter(user_id=user_id, del_flag=1).offset((page - 1) * pageSize).limit(pageSize).values( + id="id", + user_id="user__id", + username="user__username", + user_nickname="user__nickname", + department_id="user__department__id", + department_name="user__department__name", + login_ip="login_ip", + login_location="login_location", + browser="browser", + os="os", + status="status", + login_time="login_time", + session_id="session_id", + create_time="create_time", + update_time="update_time" + ) + for log in result: + log["online"] = False + for item in online_user_list: + if item["session_id"] == log["session_id"]: + log["online"] = True + return Response.success(data={ + "total": await LoginLog.filter(user_id=user_id).count(), + "result": result, + "page": page, + }) + + +@logAPI.delete("/logout/{id}", response_class=JSONResponse, response_model=BaseResponse, summary="用户强退") +@logAPI.post("/logout/{id}", response_class=JSONResponse, response_model=BaseResponse, summary="用户强退") +@Log(title="用户强退", business_type=BusinessType.DELETE) +# @Auth(permission_list=["user:btn:logout"]) +async def logout_user(request: Request, id: str = Path(description="会话ID"), + current_user: dict = Depends(LoginController.get_current_user)): + if await LoginLog.get_or_none(user_id=current_user.get("id"), session_id=id): + await request.app.state.redis.delete(f"{RedisKeyConfig.ACCESS_TOKEN.key}:{id}") + return Response.success(msg="强退成功!") + return Response.failure(msg="会话不存在!") + + +@logAPI.delete("/delete/login/{id}", response_model=BaseResponse, response_class=JSONResponse, + summary="用户删除登录日志") +@logAPI.post("/delete/login/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="用户删除登录日志") +@Log(title="用户删除登录日志", business_type=BusinessType.DELETE) +@Auth(permission_list=["login:btn:delete"]) +async def delete_login_log(id: str = Path(..., description="登录日志ID"), + current_user: dict = Depends(LoginController.get_current_user)): + if log := await LoginLog.get_or_none(id=id): + if log.user == current_user.get("id"): + log.del_flag = 0 + await log.save() + return Response.success(msg="删除成功") + else: + return Response.failure(msg="无权限删除") + else: + return Response.failure(msg="删除失败,登录日志不存在!") + + +@logAPI.delete("/deleteList/login", response_model=BaseResponse, response_class=JSONResponse, + summary="用户删除登录日志") +@logAPI.post("/deleteList/login", response_model=BaseResponse, response_class=JSONResponse, + summary="用户删除登录日志") +@Log(title="用户批量删除登录日志", business_type=BusinessType.DELETE) +@Auth(permission_list=["login:btn:delete"]) +async def delete_login_log(params: DeleteListParams, + current_user: dict = Depends(LoginController.get_current_user)): + for id in set(params.ids): + if log := await LoginLog.get_or_none(id=id): + if log.user == current_user.get("id"): + log.del_flag = 0 + await log.save() + return Response.success(msg="删除成功") + + +@logAPI.get("/operation", response_class=JSONResponse, response_model=GetOperationLogResponse, + summary="用户获取操作日志") +async def get_operation_log(request: Request, + page: int = Query(default=1, description="页码"), + pageSize: int = Query(default=10, description="每页数量"), + current_user: dict = Depends(LoginController.get_current_user), + ): + user_id = current_user.get("id") + result = await OperationLog.filter(operator_id=user_id, del_flag=1).offset((page - 1) * pageSize).limit( + pageSize).values( + id="id", + operation_name="operation_name", + operation_type="operation_type", + request_path="request_path", + request_method="request_method", + request_params="request_params", + response_result="response_result", + host="host", + location="location", + browser="browser", + os="os", + user_agent="user_agent", + operator_id="operator__id", + operator_name="operator__username", + operator_nickname="operator__nickname", + department_id="department__id", + department_name="department__name", + status="status", + operation_time="operation_time", + cost_time="cost_time" + ) + return Response.success(data={ + "total": await OperationLog.filter(operator_id=user_id).count(), + "result": result, + "page": page, + }) + + +@logAPI.delete("/delete/operation/{id}", response_model=BaseResponse, response_class=JSONResponse, + summary="用户删除操作日志") +@logAPI.post("/delete/operation/{id}", response_model=BaseResponse, response_class=JSONResponse, + summary="用户删除操作日志") +@Log(title="用户删除操作日志", business_type=BusinessType.DELETE) +@Auth(permission_list=["operation:btn:delete"]) +async def delete_operation_log(id: str = Path(..., description="操作日志id"), + current_user: dict = Depends(LoginController.get_current_user)): + if log := await OperationLog.get_or_none(id=id): + if log.operator == current_user.get("id"): + log.del_flag = 0 + await log.save() + return Response.success(msg="删除成功") + else: + return Response.failure(msg="无权限删除") + else: + return Response.failure(msg="删除失败,操作日志不存在!") + + +@logAPI.delete("/deleteList/operation", response_model=BaseResponse, response_class=JSONResponse, + summary="用户删除操作日志") +@logAPI.post("/deleteList/operation", response_model=BaseResponse, response_class=JSONResponse, + summary="用户删除操作日志") +@Log(title="用户批量删除操作日志", business_type=BusinessType.DELETE) +@Auth(permission_list=["operation:btn:delete"]) +async def delete_operation_log(params: DeleteListParams, + current_user: dict = Depends(LoginController.get_current_user)): + for id in set(params.ids): + if log := await OperationLog.get_or_none(id=id): + if log.operator == current_user.get("id"): + log.del_flag = 0 + await log.save() + return Response.success(msg="删除成功") diff --git a/api/login.py b/api/login.py new file mode 100644 index 0000000..d959599 --- /dev/null +++ b/api/login.py @@ -0,0 +1,210 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/19 01:00 +# @UpdateTime : 2025/01/19 01:00 +# @Author : sonder +# @File : login.py +# @Software : PyCharm +# @Comment : 本程序 +import uuid +from datetime import timedelta, datetime + +from fastapi import APIRouter, Request, Depends +from fastapi.encoders import jsonable_encoder +from starlette.responses import JSONResponse +from tortoise.expressions import Q + +from annotation.log import Log +from config.constant import BusinessType +from config.constant import RedisKeyConfig +from controller.login import CustomOAuth2PasswordRequestForm, LoginController +from controller.query import QueryController +from models import Department, User +from schemas.common import BaseResponse +from schemas.login import LoginParams, GetUserInfoResponse, LoginResponse, GetCaptchaResponse, GetEmailCodeParams, \ + ResetPasswordParams +from schemas.user import RegisterUserParams +from utils.captcha import Captcha +from utils.log import logger +from utils.mail import Email +from utils.password import Password +from utils.response import Response + +loginAPI = APIRouter() + + +@loginAPI.post("/login", response_class=JSONResponse, summary="用户登录") +@Log(title="用户登录", business_type=BusinessType.GRANT, log_type="login") +async def login( + request: Request, + params: CustomOAuth2PasswordRequestForm = Depends() +): + user = LoginParams( + username=params.username, + password=params.password, + loginDays=params.loginDays, + code=params.code, + uuid=params.uuid + ) + result = await LoginController.login(user) + if result["status"]: + await request.app.state.redis.set( + f'{RedisKeyConfig.ACCESS_TOKEN.key}:{result["session_id"]}', + result["accessToken"], + ex=timedelta(minutes=result["expiresIn"]), + ) + userInfo = str(jsonable_encoder(result["userInfo"])) + await request.app.state.redis.set( + f'{RedisKeyConfig.USER_INFO.key}:{result["userInfo"]["id"]}', + userInfo, + ex=timedelta(minutes=5), + ) + request.app.state.session_id = result["session_id"] + # 判断请求是否来自于api文档,如果是返回指定格式的结果,用于修复api文档认证成功后token显示undefined的bug + request_from_swagger = request.headers.get('referer').endswith('docs') if request.headers.get( + 'referer') else False + request_from_redoc = request.headers.get('referer').endswith('redoc') if request.headers.get( + 'referer') else False + if request_from_swagger or request_from_redoc: + return {'access_token': result["accessToken"], 'token_type': 'Bearer', + "expires_in": result["expiresIn"] * 60} + result.pop("status") + result.pop("expiresIn") + result.pop("session_id") + result.pop("userInfo") + return Response.success(data=result) + return Response.failure(msg="登录失败,账号或密码错误!") + + +@loginAPI.post("/register", response_class=JSONResponse, response_model=LoginResponse, summary="用户注册") +async def register(request: Request, params: RegisterUserParams): + result = await Email.verify_code(request, username=params.username, mail=params.email, code=params.code) + if not result["status"]: + return Response.error(msg=result["msg"]) + if await QueryController.register_user_before(username=params.username, phone=params.phone, email=params.email): + return Response.error(msg="注册失败,用户已存在!") + params.password = await Password.get_password_hash(input_password=params.password) + department = await Department.get_or_none(id=params.department_id) + user = await User.create( + username=params.username, + password=params.password, + nickname=params.nickname, + phone=params.phone, + email=params.email, + gender=params.gender, + department=department, + status=params.status, + ) + if user: + userParams = LoginParams( + username=params.username, + password=params.password + ) + result = await LoginController.login(userParams) + if result["status"]: + await request.app.state.redis.set( + f'{RedisKeyConfig.ACCESS_TOKEN.key}:{result["session_id"]}', + result["accessToken"], + ex=timedelta(minutes=result["expiresIn"]), + ) + userInfo = str(jsonable_encoder(result["userInfo"])) + await request.app.state.redis.set( + f'{RedisKeyConfig.USER_INFO.key}:{result["userInfo"]["id"]}', + userInfo, + ex=timedelta(minutes=5), + ) + result.pop("status") + result.pop("expiresIn") + result.pop("session_id") + result.pop("userInfo") + return Response.success(msg="注册成功!", data=result) + return Response.error(msg="注册成功!") + else: + return Response.error(msg="注册失败!") + + +@loginAPI.get("/captcha", response_class=JSONResponse, response_model=GetCaptchaResponse, summary="获取验证码") +async def get_captcha(request: Request): + captcha_result = await Captcha.create_captcha("1") + session_id = str(uuid.uuid4()) + captcha = captcha_result[0] + result = captcha_result[-1] + await request.app.state.redis.set( + f'{RedisKeyConfig.CAPTCHA_CODES.key}:{session_id}', result, ex=timedelta(minutes=2) + ) + logger.info(f'编号为{session_id}的会话获取图片验证码成功') + + return Response.success(data={ + "uuid": session_id, + "captcha": captcha, + }) + + +@loginAPI.post("/code", response_class=JSONResponse, response_model=BaseResponse, summary="获取邮件验证码") +async def get_code(request: Request, params: GetEmailCodeParams): + result = await Email.send_email(request, username=params.username, title=params.title, mail=params.mail) + if result: + return Response.success(msg="验证码发送成功!") + return Response.error(msg="验证码发送失败!") + + +@loginAPI.put("/resetPassword", response_class=JSONResponse, response_model=BaseResponse, summary="重置密码") +@loginAPI.post("/resetPassword", response_class=JSONResponse, response_model=BaseResponse, summary="重置密码") +async def reset_password(request: Request, params: ResetPasswordParams): + result = await Email.verify_code(request, username=params.username, mail=params.mail, code=params.code) + if not result["status"]: + return Response.error(msg=result["msg"]) + user = await User.get_or_none(Q(username=params.username) | Q(phone=params.username), email=params.mail) + if user: + user.password = await Password.get_password_hash(input_password=params.password) + await user.save() + return Response.success(msg="密码重置成功!") + return Response.error(msg="密码重置失败,用户不存在!") + + +@loginAPI.get("/info", response_class=JSONResponse, response_model=GetUserInfoResponse, summary="获取用户信息") +@Log(title="获取用户信息", business_type=BusinessType.SELECT) +async def info( + request: Request, + current_user: dict = Depends(LoginController.get_current_user) +): + return Response.success(data=current_user) + + +@loginAPI.get("/getRoutes", response_class=JSONResponse, summary="获取路由信息") +# @Log(title="获取路由信息", business_type=BusinessType.SELECT) +async def get_routes(request: Request, current_user: dict = Depends(LoginController.get_current_user)): + routes = await request.app.state.redis.get(f'{RedisKeyConfig.USER_ROUTES.key}:{current_user["id"]}') + if routes: + return Response.success(data=eval(routes)) + routes = await LoginController.get_user_routes(current_user["id"]) + userRoutes = str(jsonable_encoder(routes)) + await request.app.state.redis.set( + f'{RedisKeyConfig.USER_ROUTES.key}:{current_user["id"]}', + userRoutes, + ex=timedelta(minutes=5), + ) + return Response.success(data=routes) + + +@loginAPI.post("/refreshToken", response_class=JSONResponse, response_model=LoginResponse, summary="刷新token") +@Log(title="刷新token", business_type=BusinessType.GRANT) +async def refresh_token(request: Request, + current_user: dict = Depends(LoginController.get_current_user) + ): + session_id = uuid.uuid4().__str__() + accessToken = await LoginController.create_token( + data={"user": current_user, "id": current_user.get("id"), "session_id": session_id}, + expires_delta=timedelta(minutes=2 * 24 * 60)) + expiresTime = (datetime.now() + timedelta(minutes=2 * 24 * 60)).timestamp() + refreshToken = await LoginController.create_token( + data={"user": current_user, "id": current_user.get("id"), "session_id": session_id}, + expires_delta=timedelta(minutes=(4 * 24 + 2) * 60)) + return Response.success(data={"accessToken": accessToken, "refreshToken": refreshToken, "expiresTime": expiresTime}) + + +@loginAPI.post("/logout", response_class=JSONResponse, response_model=BaseResponse, summary="用户登出") +@Log(title="退出登录", business_type=BusinessType.FORCE) +async def logout(request: Request, status: bool = Depends(LoginController.logout)): + if status: + return Response.success(data="退出成功!") + return Response.error(data="登出失败!") diff --git a/api/permission.py b/api/permission.py new file mode 100644 index 0000000..b73d099 --- /dev/null +++ b/api/permission.py @@ -0,0 +1,228 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/20 20:33 +# @UpdateTime : 2025/01/20 20:33 +# @Author : sonder +# @File : permission.py +# @Software : PyCharm +# @Comment : 本程序 +from typing import Optional + +from fastapi import APIRouter, Depends, Path, Query, Request +from fastapi.responses import JSONResponse + +from annotation.log import Log +from config.constant import BusinessType +from controller.login import LoginController +from models import Permission +from schemas.common import BaseResponse +from schemas.permission import AddPermissionParams, GetPermissionInfoResponse, GetPermissionListResponse +from utils.response import Response + +permissionAPI = APIRouter( + prefix="/permission", + dependencies=[Depends(LoginController.get_current_user)] +) + + +@permissionAPI.post("/add", response_model=BaseResponse, response_class=JSONResponse, summary="新增权限") +@Log(title="新增权限", business_type=BusinessType.INSERT) +async def add_permission(request: Request, params: AddPermissionParams): + permission = await Permission.create( + name=params.name, + parent_id=params.parent_id, + path=params.path, + title=params.title, + menu_type=params.menu_type, + rank=params.rank, + show_link=params.show_link, + show_parent=params.show_parent, + active_path=params.active_path, + component=params.component, + redirect=params.redirect, + frame_src=params.frame_src, + frame_loading=params.frame_loading, + keep_alive=params.keep_alive, + auths=params.auths, + icon=params.icon, + extra_icon=params.extra_icon, + enter_transition=params.enter_transition, + leave_transition=params.leave_transition, + fixed_tag=params.fixed_tag, + hidden_tag=params.hidden_tag, + ) + if permission: + return Response.success(msg="新增权限成功!") + else: + return Response.error(msg="新增权限失败!") + + +@permissionAPI.delete("/delete/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="删除权限") +@permissionAPI.post("/delete/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="删除权限") +@Log(title="删除权限", business_type=BusinessType.DELETE) +async def delete_permission(request: Request, id: str = Path(description="权限ID")): + if permission := await Permission.get_or_none(id=id): + await permission.delete() + return Response.success(msg="删除权限成功!") + else: + return Response.error(msg="删除权限失败,权限不存在!") + + +@permissionAPI.put("/update/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="更新权限") +@permissionAPI.post("/update/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="更新权限") +@Log(title="更新权限", business_type=BusinessType.UPDATE) +async def update_permission(request: Request, params: AddPermissionParams, id: str = Path(description="权限ID"), ): + if permission := await Permission.get_or_none(id=id): + permission.name = params.name + permission.parent_id = params.parent_id + permission.path = params.path + permission.title = params.title + permission.menu_type = params.menu_type + permission.rank = params.rank + permission.show_link = params.show_link + permission.show_parent = params.show_parent + permission.active_path = params.active_path + permission.component = params.component + permission.redirect = params.redirect + permission.frame_src = params.frame_src + permission.frame_loading = params.frame_loading + permission.keep_alive = params.keep_alive + permission.auths = params.auths + permission.icon = params.icon + permission.extra_icon = params.extra_icon + permission.enter_transition = params.enter_transition + permission.leave_transition = params.leave_transition + permission.fixed_tag = params.fixed_tag + permission.hidden_tag = params.hidden_tag + await permission.save() + return Response.success(msg="更新权限成功!") + else: + return Response.error(msg="更新权限失败,权限不存在!") + + +@permissionAPI.get("/info/{id}", response_model=GetPermissionInfoResponse, response_class=JSONResponse, + summary="查询权限详情") +@Log(title="查询权限详情", business_type=BusinessType.SELECT) +async def get_permission(request: Request, id: str = Path(description="权限ID")): + if permission := await Permission.get_or_none(permission_id=id): + permission = await permission.first().values( + id="id", + create_by="create_by", + create_time="create_time", + update_by="update_by", + update_time="update_time", + menu_type="menu_type", + parent_id="parent_id", + path="path", + title="title", + name="name", + rank="rank", + redirect="redirect", + component="component", + icon="icon", + extra_icon="extra_icon", + enter_transition="enter_transition", + leave_transition="leave_transition", + active_path="active_path", + auths="auths", + frame_src="frame_src", + frame_loading="frame_loading", + keep_alive="keep_alive", + hidden_tag="hidden_tag", + fixed_tag="fixed_tag", + show_link="show_link", + show_parent="show_parent", + ) + return Response.success(msg="查询权限详情成功!", data=permission) + else: + return Response.error(msg="查询权限详情失败,权限不存在!") + + +@permissionAPI.get("/list", response_model=GetPermissionListResponse, response_class=JSONResponse, + summary="查询权限列表") +@Log(title="查询权限列表", business_type=BusinessType.SELECT) +async def get_permission_list( + request: Request, + page: int = Query(default=1, description="当前页码"), + pageSize: int = Query(default=10, description="每页条数"), + id: Optional[str] = Query(default=None, description="主键"), + name: Optional[str] = Query(default=None, description="权限名称"), + parentId: Optional[str] = Query(default=None, description="父权限ID"), + path: Optional[str] = Query(default=None, description="权限路径"), + rank: Optional[int] = Query(default=None, description="排序权重"), + menuType: Optional[int] = Query(default=None, description="菜单类型(0菜单、1iframe、2外链、3按钮)"), + showLink: Optional[bool] = Query(default=None, description="显示菜单"), + showParent: Optional[bool] = Query(default=None, description="显示父级菜单"), + activePath: Optional[str] = Query(default=None, description="激活路径"), + component: Optional[str] = Query(default=None, description="组件路径"), + redirect: Optional[str] = Query(default=None, description="重定向路径"), + frameSrc: Optional[str] = Query(default=None, description="iframe路径"), + frameLoading: Optional[bool] = Query(default=None, description="iframe加载动画"), + keepAlive: Optional[bool] = Query(default=None, description="缓存组件"), + auths: Optional[str] = Query(default=None, description="权限标识"), + icon: Optional[str] = Query(default=None, description="菜单图标"), + extraIcon: Optional[str] = Query(default=None, description="右侧图标"), + enterTransition: Optional[str] = Query(default=None, description="进场动画"), + leaveTransition: Optional[str] = Query(default=None, description="离场动画"), + fixedTag: Optional[bool] = Query(default=None, description="固定标签页"), + hiddenTag: Optional[bool] = Query(default=None, description="隐藏标签页") +): + filterArgs = { + f'{k}__contains': v for k, v in { + "id": id, + "name": name, + "parent_id": parentId, + "path": path, + "rank": rank, + "menu_type": menuType, + "show_link": showLink, + "show_parent": showParent, + "active_path": activePath, + "component": component, + "redirect": redirect, + "frame_src": frameSrc, + "frame_loading": frameLoading, + "keep_alive": keepAlive, + "auths": auths, + "icon": icon, + "extra_icon": extraIcon, + "enter_transition": enterTransition, + "leave_transition": leaveTransition, + "fixed_tag": fixedTag, + "hidden_tag": hiddenTag + }.items() if v + } + total = await Permission.filter(**filterArgs).count() + result = await Permission.filter(**filterArgs).offset((page - 1) * pageSize).limit(pageSize).order_by( + 'rank').values( + id="id", + create_by="create_by", + create_time="create_time", + update_by="update_by", + update_time="update_time", + menu_type="menu_type", + parent_id="parent_id", + title="title", + name="name", + path="path", + component="component", + rank="rank", + redirect="redirect", + icon="icon", + extra_icon="extra_icon", + enter_transition="enter_transition", + leave_transition="leave_transition", + active_path="active_path", + auths="auths", + frame_src="frame_src", + frame_loading="frame_loading", + keep_alive="keep_alive", + hidden_tag="hidden_tag", + fixed_tag="fixed_tag", + show_link="show_link", + show_parent="show_parent" + ) + return Response.success(data={ + "total": total, + "result": result, + "page": page, + }) diff --git a/api/role.py b/api/role.py new file mode 100644 index 0000000..a64a4f8 --- /dev/null +++ b/api/role.py @@ -0,0 +1,333 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/20 22:41 +# @UpdateTime : 2025/01/20 22:41 +# @Author : sonder +# @File : role.py +# @Software : PyCharm +# @Comment : 本程序 +from typing import Optional + +from fastapi import APIRouter, Depends, Path, Query, Request +from fastapi.responses import JSONResponse + +from annotation.log import Log +from config.constant import BusinessType +from controller.login import LoginController +from models import Role, Permission, RolePermission, Department +from schemas.common import BaseResponse +from schemas.role import AddRoleParams, AddRolePermissionParams, GetRolePermissionInfoResponse, \ + GetRolePermissionListResponse +from utils.common import filterKeyValues +from utils.response import Response + +roleAPI = APIRouter( + prefix="/role", + dependencies=[Depends(LoginController.get_current_user)] +) + + +@roleAPI.post("/add", response_model=BaseResponse, response_class=JSONResponse, summary="新增角色") +@Log(title="新增角色", business_type=BusinessType.INSERT) +async def add_role(request: Request, params: AddRoleParams): + if await Role.get_or_none(code=params.role_code, department_id=params.department_id, del_flag=1): + return Response.error(msg="角色编码已存在!") + department = await Department.get_or_none(id=params.department_id, del_flag=1) + if department: + role = await Role.create( + code=params.code, + name=params.name, + description=params.description, + status=params.status, + department_id=department.id, + ) + else: + role = await Role.create( + code=params.role_code, + name=params.role_name, + status=params.status, + description=params.role_description, + department_id=None, + ) + if role: + return Response.success(msg="新增角色成功!") + return Response.error(msg="新增角色失败!") + + +@roleAPI.delete("/delete/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="删除角色") +@roleAPI.post("/delete/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="删除角色") +@Log(title="删除角色", business_type=BusinessType.DELETE) +async def delete_role(request: Request, id: int = Path(..., description="角色ID")): + if role := await Role.get_or_none(id=id, del_flag=1): + # 移除相应角色权限 + await RolePermission.filter(role_id=role.id).delete() + await role.delete() + return Response.success(msg="删除角色成功!") + return Response.error(msg="删除角色失败!") + + +@roleAPI.put("/update/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="修改角色") +@roleAPI.post("/update/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="修改角色") +@Log(title="修改角色", business_type=BusinessType.UPDATE) +async def update_role(request: Request, params: AddRoleParams, id: str = Path(..., description="角色ID")): + if role := await Role.get_or_none(id=id, del_flag=1): + role.code = params.code + role.name = params.name + role.description = params.description + department = await Department.get_or_none(id=params.department_id, del_flag=1) + role.status = params.status + if department: + role.department_id = department.id + else: + role.department_id = None + await role.save() + return Response.success(msg="修改角色成功!") + return Response.error(msg="修改角色失败!") + + +@roleAPI.get("/info/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="查询角色详情") +@Log(title="查询角色详情", business_type=BusinessType.SELECT) +async def get_role_info(request: Request, id: int = Path(..., description="角色ID")): + if role := await Role.get_or_none(id=id, del_flag=1).values( + id="id", + create_by="create_by", + create_time="create_time", + update_by="update_by", + update_time="update_time", + code="code", + name="name", + status="status", + description="description", + department_id="department_id", + department_name="department__name", + department_principal="department__principal", + department_phone="department__phone", + department_email="department__email", + ): + return Response.success(data=role) + return Response.error(msg="查询角色详情失败!") + + +@roleAPI.get("/list", response_model=BaseResponse, response_class=JSONResponse, summary="查询角色列表") +@Log(title="查询角色列表", business_type=BusinessType.SELECT) +async def get_role_list( + request: Request, + page: int = Query(1, description="页码"), + pageSize: int = Query(10, description="每页数量"), + name: Optional[str] = Query(None, description="角色名称"), + code: Optional[str] = Query(None, description="角色编码"), + description: Optional[str] = Query(None, description="角色描述"), + department_id: Optional[str] = Query(None, description="所属部门ID"), + status: Optional[int] = Query(None, description="状态"), + current_user: dict = Depends(LoginController.get_current_user) +): + filterArgs = { + f'{k}__contains': v for k, v in { + "name": name, + "code": code, + "description": description, + "status": status + }.items() if v + } + # 如果未提供 department_id,则使用当前用户的部门 ID + if not department_id: + department_id = current_user.get("department_id") + + # 查询当前部门及其下属部门的角色 + all_roles = await get_role_and_subroles(department_id, filterArgs) + + # 分页处理 + total = len(all_roles) + paginated_roles = all_roles[(page - 1) * pageSize: page * pageSize] + + return Response.success(data={ + "result": paginated_roles, + "total": total, + "page": page + }) + + +async def get_department_and_subdepartments(department_id: str, visited: set = None): + """ + 递归查询当前部门及其所有下属部门的 ID。 + + :param department_id: 当前部门 ID + :param visited: 已访问的部门 ID 集合,用于避免循环依赖 + :return: 部门 ID 列表 + """ + if visited is None: + visited = set() # 初始化已访问的部门 ID 集合 + + # 如果当前部门 ID 已经访问过,直接返回空列表,避免死循环 + if department_id in visited: + return [] + + visited.add(department_id) # 标记当前部门 ID 为已访问 + + # 查询当前部门 + current_department = await Department.filter( + id=department_id + ).values_list("id", flat=True) + + # 查询直接子部门 + sub_departments = await Department.filter( + parent_id=department_id + ).values_list("id", flat=True) + + # 递归查询子部门的子部门 + for sub_department_id in sub_departments[:]: # 使用切片复制避免修改迭代中的列表 + sub_sub_departments = await get_department_and_subdepartments(sub_department_id, visited) + sub_departments.extend(sub_sub_departments) + + # 合并当前部门和所有下属部门的 ID + return current_department + sub_departments + + +async def get_role_and_subroles(department_id: str, filterArgs: dict): + """ + 查询当前部门及其下属部门的角色。 + + :param department_id: 当前部门 ID + :param filterArgs: 过滤条件 + :return: 角色列表 + """ + # 递归查询当前部门及其下属部门的 ID + department_ids = await get_department_and_subdepartments(department_id) + + # 查询这些部门的角色 + roles = await Role.filter( + department__id__in=department_ids, + **filterArgs + ).values( + id="id", + create_by="create_by", + create_time="create_time", + update_by="update_by", + update_time="update_time", + code="code", + name="name", + status="status", + description="description", + department_id="department__id", + department_name="department__name", + department_principal="department__principal", + department_phone="department__phone", + department_email="department__email", + ) + + # 根据 id 去重 + unique_roles = [] + seen_ids = set() # 用于记录已经处理过的角色 ID + for role in roles: + if role["id"] not in seen_ids: + unique_roles.append(role) + seen_ids.add(role["id"]) + + return unique_roles + + +@roleAPI.post("/addPermission", response_model=BaseResponse, response_class=JSONResponse, summary="新增角色权限") +@Log(title="新增角色权限", business_type=BusinessType.INSERT) +async def add_role_permission(request: Request, params: AddRolePermissionParams, + id: str = Path(..., description="角色ID")): + if role := await Role.get_or_none(id=id, del_flag=1): + # 已有角色权限 + rolePermissions = await RolePermission.filter(role_id=id).all().values("permission_id") + rolePermissions = await filterKeyValues(rolePermissions, "permission_id") + # 利用集合筛选出角色权限中不存在的权限 + add_list = set(params.permission_ids).difference(set(rolePermissions)) + # 循环添加角色权限 + for item in add_list: + permission = await Permission.get_or_none(id=item, del_flag=1) + if permission: + await RolePermission.create( + role_id=role.id, + permission_id=permission.id + ) + return Response.success(msg="新增角色权限成功!") + return Response.error(msg="新增角色权限失败!") + + +@roleAPI.delete("/deletePermission/{id}", response_model=BaseResponse, response_class=JSONResponse, + summary="删除角色权限") +@roleAPI.post("/deletePermission/{id}", response_model=BaseResponse, response_class=JSONResponse, + summary="删除角色权限") +@Log(title="删除角色权限", business_type=BusinessType.DELETE) +async def delete_role_permission(request: Request, id: int = Path(..., description="角色权限ID")): + if rolePermission := await RolePermission.get_or_none(id=id, del_flag=1): + await rolePermission.delete() + return Response.success(msg="删除角色权限成功!") + return Response.error(msg="删除角色权限失败!") + + +@roleAPI.put("/updatePermission/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="修改角色权限") +@roleAPI.post("/updatePermission/{id}", response_model=BaseResponse, response_class=JSONResponse, + summary="修改角色权限") +@Log(title="修改角色权限", business_type=BusinessType.UPDATE) +async def update_role_permission(request: Request, params: AddRolePermissionParams, + id: str = Path(..., description="角色ID")): + if role := await Role.get_or_none(id=id, del_flag=1): + # 已有角色权限 + rolePermissions = await RolePermission.filter(role_id=role.id).all().values("permission_id") + rolePermissions = await filterKeyValues(rolePermissions, "permission_id") + # 利用集合筛选出角色权限中不存在的权限 + delete_list = set(rolePermissions).difference(set(params.permission_ids)) + # 利用集合筛选出角色权限中新增的权限 + add_list = set(params.permission_ids).difference(set(rolePermissions)) + # 循环删除角色权限 + for item in delete_list: + await RolePermission.filter(role_id=id, permission_id=item).delete() + # 循环添加角色权限 + for item in add_list: + await RolePermission.create(role_id=id, permission_id=item) + return Response.success(msg="修改角色权限成功!") + return Response.error(msg="修改角色权限失败!") + + +@roleAPI.get("/permissionInfo/{id}", response_model=GetRolePermissionInfoResponse, response_class=JSONResponse, + summary="获取角色权限信息") +@Log(title="获取角色权限信息", business_type=BusinessType.SELECT) +async def get_role_permission_info(request: Request, id: int = Path(..., description="角色权限ID")): + if rolePermission := await RolePermission.get_or_none(id=id, del_flag=1): + data = await rolePermission.first().values( + id="id", + create_by="create_by", + create_time="create_time", + update_by="update_by", + update_time="update_time", + role_id="role__id", + role_name="role__name", + role_code="role__code", + permission_id="permission__id", + permission_name="permission__title", + permission_auth="permission__auths", + permission_type="permission__menu_type" + ) + return Response.success(data=data) + return Response.error(msg="获取角色权限信息失败!") + + +@roleAPI.get("/permissionList/{id}", response_model=GetRolePermissionListResponse, response_class=JSONResponse, + summary="获取角色权限列表") +@Log(title="获取角色权限列表", business_type=BusinessType.SELECT) +async def get_role_permission_list(request: Request, id: str = Path(..., description="角色ID")): + total = await RolePermission.filter(role_id=id).count() + data = await RolePermission.filter(role_id=id).values( + id="id", + create_by="create_by", + create_time="create_time", + update_by="update_by", + update_time="update_time", + role_id="role__id", + role_name="role__name", + role_code="role__code", + permission_id="permission__id", + permission_parent_id="permission__parent_id", + permission_name="permission__title", + permission_auth="permission__auths", + permission_type="permission__menu_type" + ) + return Response.success(data={ + "result": data, + "total": total, + "page": 1 + }) diff --git a/api/server.py b/api/server.py new file mode 100644 index 0000000..d398ba5 --- /dev/null +++ b/api/server.py @@ -0,0 +1,112 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/02/04 15:25 +# @UpdateTime : 2025/02/04 15:25 +# @Author : sonder +# @File : server.py +# @Software : PyCharm +# @Comment : 本程序 +import os +import platform +import socket +import time + +import psutil +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse + +from annotation.log import Log +from config.constant import BusinessType +from controller.login import LoginController +from schemas.server import GetServerInfoResponse, CpuInfo, MemoryInfo, SystemInfo, PythonInfo, SystemFiles, \ + GetSystemInfoResult +from utils.common import bytes2human +from utils.response import Response + +serverAPI = APIRouter( + prefix="/server", + dependencies=[Depends(LoginController.get_current_user)] +) + + +@serverAPI.get("", response_class=JSONResponse, response_model=GetServerInfoResponse, summary="获取服务器信息") +@Log(title="获取服务器信息", business_type=BusinessType.SELECT) +async def get_server_info(request: Request): + # CPU信息 + # 获取CPU总核心数 + cpu_num = psutil.cpu_count(logical=True) + cpu_usage_percent = psutil.cpu_times_percent() + cpu_used = cpu_usage_percent.user + cpu_sys = cpu_usage_percent.system + cpu_free = cpu_usage_percent.idle + cpu = CpuInfo(cpuNum=cpu_num, used=cpu_used, sys=cpu_sys, free=cpu_free) + + # 内存信息 + memory_info = psutil.virtual_memory() + memory_total = bytes2human(memory_info.total) + memory_used = bytes2human(memory_info.used) + memory_free = bytes2human(memory_info.free) + memory_usage = memory_info.percent + mem = MemoryInfo(total=memory_total, used=memory_used, free=memory_free, usage=memory_usage) + + # 主机信息 + # 获取主机名 + hostname = socket.gethostname() + # 获取IP + computer_ip = socket.gethostbyname(hostname) + os_name = platform.platform() + computer_name = platform.node() + os_arch = platform.machine() + user_dir = os.path.abspath(os.getcwd()) + sys = SystemInfo( + computerIp=computer_ip, computerName=computer_name, osArch=os_arch, osName=os_name, userDir=user_dir + ) + + # python解释器信息 + current_pid = os.getpid() + current_process = psutil.Process(current_pid) + python_name = current_process.name() + python_version = platform.python_version() + python_home = current_process.exe() + start_time_stamp = current_process.create_time() + start_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(start_time_stamp)) + current_time_stamp = time.time() + difference = current_time_stamp - start_time_stamp + # 将时间差转换为天、小时和分钟数 + days = int(difference // (24 * 60 * 60)) # 每天的秒数 + hours = int((difference % (24 * 60 * 60)) // (60 * 60)) # 每小时的秒数 + minutes = int((difference % (60 * 60)) // 60) # 每分钟的秒数 + run_time = f'{days}天{hours}小时{minutes}分钟' + # 获取当前Python程序的pid + pid = os.getpid() + # 获取该进程的内存信息 + current_process_memory_info = psutil.Process(pid).memory_info() + py = PythonInfo( + name=python_name, + version=python_version, + startTime=start_time, + runTime=run_time, + home=python_home, + total=bytes2human(memory_info.available), + used=bytes2human(current_process_memory_info.rss), + free=bytes2human(memory_info.available - current_process_memory_info.rss), + usage=round((current_process_memory_info.rss / memory_info.available) * 100, 2), + ) + + # 磁盘信息 + io = psutil.disk_partitions() + sys_files = [] + for i in io: + o = psutil.disk_usage(i.device) + disk_data = SystemFiles( + dirName=i.device, + sysTypeName=i.fstype, + typeName='本地固定磁盘(' + i.mountpoint.replace('\\', '') + ')', + total=bytes2human(o.total), + used=bytes2human(o.used), + free=bytes2human(o.free), + usage=f'{psutil.disk_usage(i.device).percent}%', + ) + sys_files.append(disk_data) + + result = GetSystemInfoResult(cpu=cpu, memory=mem, system=sys, python=py, systemFiles=sys_files) + return Response.success(data=result) diff --git a/api/user.py b/api/user.py new file mode 100644 index 0000000..37f60b0 --- /dev/null +++ b/api/user.py @@ -0,0 +1,349 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/20 00:31 +# @UpdateTime : 2025/01/20 00:31 +# @Author : sonder +# @File : user.py +# @Software : PyCharm +# @Comment : 本程序 +import os +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, Path, Query, UploadFile, File, Request +from fastapi.responses import JSONResponse + +from annotation.auth import Auth +from annotation.log import Log +from config.constant import BusinessType +from config.env import UploadConfig +from controller.login import LoginController +from controller.query import QueryController +from exceptions.exception import ModelValidatorException +from models import File as FileModel +from models import Role, Department +from models.user import User, UserRole +from schemas.common import BaseResponse +from schemas.department import GetDepartmentListResponse +from schemas.file import UploadFileResponse +from schemas.user import AddUserParams, GetUserListResponse, GetUserInfoResponse, UpdateUserParams, \ + AddUserRoleParams, GetUserRoleInfoResponse, UpdateUserRoleParams, GetUserPermissionListResponse, ResetPasswordParams +from utils.common import filterKeyValues +from utils.password import Password +from utils.response import Response + +userAPI = APIRouter(prefix="/user", dependencies=[Depends(LoginController.get_current_user)]) + + +@userAPI.post("/add", response_class=JSONResponse, response_model=BaseResponse, summary="新增用户") +@Log(title="新增用户", business_type=BusinessType.INSERT) +async def add_user( + request: Request, + params: AddUserParams +): + if await QueryController.register_user_before(username=params.username, phone=params.phone, email=params.email): + return Response.error(msg="添加失败,用户已存在!") + params.password = await Password.get_password_hash(input_password=params.password) + department = await Department.get_or_none(id=params.department_id) + user = await User.create( + username=params.username, + password=params.password, + nickname=params.nickname, + phone=params.phone, + email=params.email, + gender=params.gender, + department=department, + status=params.status, + ) + if user: + return Response.success(msg="添加成功!") + else: + return Response.error(msg="添加失败!") + + +@userAPI.delete("/delete/{id}", response_class=JSONResponse, response_model=BaseResponse, summary="删除用户") +@userAPI.post("/delete/{id}", response_class=JSONResponse, response_model=BaseResponse, summary="删除用户") +@Log(title="删除用户", business_type=BusinessType.DELETE) +async def delete_user( + request: Request, + id: str = Path(..., description="用户ID")): + if user := await User.get_or_none(id=id): + await user.delete() + return Response.success(msg="删除成功!") + else: + return Response.error(msg="删除失败,用户不存在!") + + +@userAPI.put("/update/{id}", response_class=JSONResponse, response_model=BaseResponse, summary="更新用户") +@userAPI.post("/update/{id}", response_class=JSONResponse, response_model=BaseResponse, summary="更新用户") +@Log(title="更新用户", business_type=BusinessType.UPDATE) +async def update_user( + request: Request, + params: UpdateUserParams, + id: str = Path(..., description="用户ID")): + if user := await User.get_or_none(id=id): + user.username = params.username + user.nickname = params.nickname + user.phone = params.phone + user.email = params.email + user.gender = params.gender + user.status = params.status + if department := await Department.get_or_none(id=params.department_id): + user.department = department + else: + user.department = None + await user.save() + return Response.success(msg="更新成功!") + else: + return Response.error(msg="更新失败,用户不存在!") + + +@userAPI.get("/info/{id}", response_class=JSONResponse, response_model=GetUserInfoResponse, summary="获取用户信息") +@Log(title="获取用户信息", business_type=BusinessType.SELECT) +async def get_user_info(request: Request, id: str = Path(..., description="用户ID")): + if user := await User.get_or_none(id=id): + user = await user.first().values( + id="id", + create_time="create_time", + update_time="update_time", + username="username", + email="email", + phone="phone", + nickname="nickname", + gender="gender", + status="status", + department_id="department__id", + ) + return Response.success(data=user) + else: + return Response.error(msg="用户不存在!") + + +@userAPI.get("/list", response_class=JSONResponse, response_model=GetUserListResponse, summary="获取用户列表") +@Log(title="获取用户列表", business_type=BusinessType.SELECT) +@Auth(["user:btn:queryUser"]) +async def get_user_list( + request: Request, + page: int = Query(default=1, description="当前页码"), + pageSize: int = Query(default=10, description="每页数量"), + username: Optional[str] = Query(default=None, description="用户名"), + nickname: Optional[str] = Query(default=None, description="昵称"), + phone: Optional[str] = Query(default=None, description="手机号"), + email: Optional[str] = Query(default=None, description="邮箱"), + gender: Optional[str] = Query(default=None, description="性别"), + status: Optional[str] = Query(default=None, description="状态"), + department_id: Optional[str] = Query(default=None, description="部门ID"), +): + filterArgs = { + f'{k}__contains': v for k, v in { + 'username': username, + 'nickname': nickname, + 'phone': phone, + 'email': email, + 'gender': gender, + 'status': status, + 'department_id': department_id + }.items() if v + } + total = await User.filter(**filterArgs).count() + result = await User.filter(**filterArgs).offset((page - 1) * pageSize).limit(pageSize).values( + id="id", + create_time="create_time", + update_time="update_time", + username="username", + email="email", + phone="phone", + nickname="nickname", + gender="gender", + status="status", + department_id="department__id", + ) + return Response.success(data={ + "result": result, + "total": total, + "page": page + }) + + +@userAPI.post("/addRole", response_model=BaseResponse, response_class=JSONResponse, summary="添加用户角色") +@Log(title="添加用户角色", business_type=BusinessType.INSERT) +async def add_user_role(request: Request, params: AddUserRoleParams): + if await UserRole.get_or_none(user_id=params.user_id, role_id=params.role_id, del_flag=1): + return Response.error(msg="该用户已存在该角色!") + if user := await User.get_or_none(id=params.user_id, del_flag=1): + if role := await Role.get_or_none(id=params.role_id, del_flag=1): + userRole = await UserRole.create(user_id=user.id, role_id=role.id) + if userRole: + return Response.success(msg="添加成功!") + else: + return Response.error(msg="添加失败!") + else: + return Response.error(msg="添加失败,角色不存在!") + else: + return Response.error(msg="添加失败,用户不存在!") + + +@userAPI.delete("/deleteRole/{id}", response_model=BaseResponse, response_class=JSONResponse, + summary="删除用户角色") +@userAPI.post("/deleteRole/{id}", response_model=BaseResponse, response_class=JSONResponse, + summary="删除用户角色") +@Log(title="删除用户角色", business_type=BusinessType.DELETE) +async def delete_user_role(request: Request, id: str = Path(description="用户角色ID")): + if userRole := await UserRole.get_or_none(id=id, del_flag=1): + await userRole.delete() + return Response.success(msg="删除成功!") + else: + return Response.error(msg="删除失败,用户角色不存在!") + + +@userAPI.put("/updateRole", response_model=BaseResponse, response_class=JSONResponse, summary="修改用户角色") +@userAPI.post("/updateRole", response_model=BaseResponse, response_class=JSONResponse, + summary="修改用户角色") +@Log(title="修改用户角色", business_type=BusinessType.UPDATE) +async def update_user_role(request: Request, params: UpdateUserRoleParams): + # 获取用户已有角色 + userRoles = await UserRole.filter(user_id=params.user_id, del_flag=1).values("role_id") + userRoles = await filterKeyValues(userRoles, "role_id") + # 利用集合找到需要添加的角色 + addRoles = set(params.role_ids).difference(set(userRoles)) + # 利用集合找到需要删除的角色 + deleteRoles = set(userRoles).difference(set(params.role_ids)) + # 添加角色 + for role_id in addRoles: + if role := await Role.get_or_none(id=role_id, del_flag=1): + await UserRole.create(user_id=params.user_id, role_id=role.id) + # 删除角色 + for role_id in deleteRoles: + if userRole := await UserRole.get_or_none(user_id=params.user_id, role_id=role_id, del_flag=1): + await userRole.delete() + return Response.success(msg="修改成功!") + + +@userAPI.get("/roleInfo/{id}", response_model=GetUserRoleInfoResponse, response_class=JSONResponse, + summary="获取用户角色信息") +@Log(title="获取用户角色信息", business_type=BusinessType.SELECT) +async def get_user_role_info(request: Request, id: str = Path(description="用户角色ID")): + if userRole := await UserRole.get_or_none(id=id, del_flag=1): + data = await userRole.first().values( + id="id", + user_id="user__id", + user_name="user__username", + role_name="role__name", + role_code="role__code", + role_id="role__id", + create_time="create_time", + update_time="update_time" + ) + return Response.success(data=data) + else: + return Response.error(msg="获取失败,用户角色不存在!") + + +@userAPI.get("/roleList/{id}", response_model=GetDepartmentListResponse, response_class=JSONResponse, + summary="获取用户角色列表") +@Log(title="获取用户角色列表", business_type=BusinessType.SELECT) +async def get_user_role_list( + request: Request, + id: str = Path(description="用户ID"), +): + result = await UserRole.filter(user_id=id).values( + id="id", + department_id="user__department__id", + department_name="user__department__name", + department_phone="user__department__phone", + department_principal="user__department__principal", + department_email="user__department__email", + role_name="role__name", + role_code="role__code", + role_id="role__id", + create_time="create_time", + update_time="update_time" + ) + return Response.success(data={ + "result": result, + "total": len(result), + "page": 1 + }) + + +@userAPI.get("/permissionList/{id}", response_class=JSONResponse, response_model=GetUserPermissionListResponse, + summary="获取用户权限列表") +@Log(title="获取用户权限列表", business_type=BusinessType.SELECT) +async def get_user_permission_list(request: Request, id: str = Path(description="用户ID")): + permissions = await QueryController.get_user_permissions(user_id=id) + permissions = await filterKeyValues(permissions, "id") + # 获取用户角色 + return Response.success(data=list(set(permissions))) + + +@userAPI.post("/avatar/{id}", response_model=UploadFileResponse, response_class=JSONResponse, summary="上传用户头像") +@Log(title="上传用户头像", business_type=BusinessType.UPDATE) +async def upload_user_avatar( + request: Request, + id: str = Path(description="用户ID"), + file: UploadFile = File(...)): + if user := await User.get_or_none(id=id): + image_mimetypes = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/svg+xml', + 'image/bmp', + 'image/webp', + 'image/tiff' + ] + if file.content_type not in image_mimetypes: + raise ModelValidatorException(message="文件类型不支持") + + # 2. 生成唯一的文件名 + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + unique_filename = f"{id}_{timestamp}" + + # 3. 保存文件到服务器 + file_path = os.path.join(UploadConfig.UPLOAD_PATH, unique_filename) + with open(file_path, "wb") as buffer: + buffer.write(await file.read()) + + # 4. 构建文件的相对路径和绝对路径 + relative_path = os.path.join(UploadConfig.UPLOAD_PREFIX, unique_filename) # 相对路径 + absolute_path = os.path.abspath(file_path) # 绝对路径 + + # 5. 将文件信息保存到数据库 + file_record = await FileModel.create( + name=file.filename, + size=os.path.getsize(file_path), + file_type=file.content_type, + absolute_path=absolute_path, + relative_path=relative_path, + uploader_id=id, + ) + user.avatar = f"/file/{file_record.id}" + await user.save() + result = await file_record.first().values( + id="id", + name="name", + size="size", + file_type="file_type", + relative_path="relative_path", + absolute_path="absolute_path", + uploader_id="uploader__id", + uploader_username="uploader__username", + uploader_nickname="uploader__nickname", + uploader_department_id="uploader__department__id", + uploader_department_name="uploader__department__name", + create_time="create_time", + update_time="update_time", + ) + return Response.success(data=result) + return Response.failure(msg="用户不存在!") + + +@userAPI.put("/resetPassword/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="重置用户密码") +@userAPI.post("/resetPassword/{id}", response_model=BaseResponse, response_class=JSONResponse, summary="重置用户密码") +@Log(title="重置用户密码", business_type=BusinessType.UPDATE) +@Auth(permission_list=["user:btn:reset_password"]) +async def reset_user_password(request: Request, params: ResetPasswordParams, id: str = Path(description="用户ID")): + if user := await User.get_or_none(id=id): + user.password = await Password.get_password_hash(params.password) + await user.save() + return Response.success(msg="重置密码成功!") + return Response.failure(msg="用户不存在!") diff --git a/app.py b/app.py new file mode 100644 index 0000000..88655f5 --- /dev/null +++ b/app.py @@ -0,0 +1,101 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/17 21:18 +# @UpdateTime : 2025/01/17 21:18 +# @Author : sonder +# @File : app.py +# @Software : PyCharm +# @Comment : 本程序为项目启动文件 + +from contextlib import asynccontextmanager + +import uvicorn +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi + +from api.cache import cacheAPI +from api.department import departmentAPI +from api.file import fileAPI +from api.log import logAPI +from api.login import loginAPI +from api.permission import permissionAPI +from api.role import roleAPI +from api.server import serverAPI +from api.user import userAPI +from api.i18n import i18nAPI +from config.database import init_db, close_db +from config.env import AppConfig +from config.get_redis import Redis +from exceptions.handle import handle_exception +from middlewares.handle import handle_middleware +from utils.log import logger + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info(f'{AppConfig.app_name}开始启动') + app.state.redis = await Redis.create_redis_pool() + logger.info(f'{AppConfig.app_name}启动成功') + await init_db() + yield + await close_db() + await Redis.close_redis_pool(app) + + +app = FastAPI( + title=AppConfig.app_name, + description=f'{AppConfig.app_name}接口文档', + version=AppConfig.app_version, + lifespan=lifespan, +) +# 加载中间件处理方法 +handle_middleware(app) +# 加载全局异常处理方法 +handle_exception(app) + + +def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + openapi_schema = get_openapi( + title="My API", + version="1.0.0", + routes=app.routes, + ) + # 修改 Swagger UI 配置 + openapi_schema["components"]["securitySchemes"] = { + "Bearer": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } + app.openapi_schema = openapi_schema + return app.openapi_schema + + +api_list = [ + {'api': loginAPI, 'tags': ['登录管理']}, + {'api': userAPI, 'tags': ['用户管理']}, + {'api': departmentAPI, 'tags': ['部门管理']}, + {'api': roleAPI, 'tags': ['角色管理']}, + {'api': permissionAPI, 'tags': ['权限管理']}, + {'api': fileAPI, 'tags': ['文件管理']}, + {'api': logAPI, 'tags': ['日志管理']}, + {'api': cacheAPI, 'tags': ['缓存管理']}, + {'api': serverAPI, 'tags': ['服务器管理']}, + {'api': i18nAPI, 'tags': ['国际化管理']}, + +] + +for api in api_list: + app.include_router(router=api.get("api"), tags=api.get("tags")) + +if __name__ == '__main__': + uvicorn.run( + app='app:app', + host=AppConfig.app_host, + port=AppConfig.app_port, + root_path=AppConfig.app_root_path, + reload=AppConfig.app_reload, + log_config="uvicorn_config.json" + ) diff --git a/assets/font/MiSans-Medium.ttf b/assets/font/MiSans-Medium.ttf new file mode 100644 index 0000000..79b9d10 Binary files /dev/null and b/assets/font/MiSans-Medium.ttf differ diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..a8577e8 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,7 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/17 21:13 +# @UpdateTime : 2025/01/17 21:13 +# @Author : sonder +# @File : __init__.py.py +# @Software : PyCharm +# @Comment : 本程序 diff --git a/config/constant.py b/config/constant.py new file mode 100644 index 0000000..d981860 --- /dev/null +++ b/config/constant.py @@ -0,0 +1,265 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 02:39 +# @UpdateTime : 2025/01/18 02:39 +# @Author : sonder +# @File : constant.py +# @Software : PyCharm +# @Comment : 本程序 +from enum import Enum + + +class BusinessType(Enum): + """ + 业务操作类型枚举 + 定义系统中的操作类型,用于记录和分类业务日志。 + + 枚举值说明: + - OTHER: 其它操作,默认值为 0 + - SELECT: 查询操作,默认值为1 + - INSERT: 新增操作,值为 2 + - UPDATE: 修改操作,值为 3 + - DELETE: 删除操作,值为 4 + - GRANT: 授权操作,值为 5 + - EXPORT: 导出数据操作,值为 6 + - IMPORT: 导入数据操作,值为 7 + - FORCE: 强制退出操作,值为 8 + - GENCODE: 代码生成操作,值为 9 + - CLEAN: 清空数据操作,值为 10 + """ + OTHER = 0 + """ + 其它操作,默认值为 0 + """ + SELECT = 1 + """ + 查询操作,默认值为1 + """ + INSERT = 2 + """ + 新增操作,值为 2 + """ + UPDATE = 3 + """ + 修改操作,值为 3 + """ + DELETE = 4 + """ + 删除操作,值为 4 + """ + GRANT = 5 + """ + 授权操作,值为 5 + """ + EXPORT = 6 + """ + 导出数据操作,值为 6 + """ + IMPORT = 7 + """ + 导入数据操作,值为 7 + """ + FORCE = 8 + """ + 强制退出操作,值为 8 + """ + GENCODE = 9 + """ + 代码生成操作,值为 9 + """ + CLEAN = 10 + """ + 清空数据操作,值为 10 + """ + + +class CommonConstant: + """ + 常用常量定义类,包含系统中常用的字符串标识和布尔值。 + + 属性: + WWW: `www.` 主域的前缀。 + HTTP: `http://` 协议前缀。 + HTTPS: `https://` 协议前缀。 + LOOKUP_RMI: RMI(远程方法调用)协议前缀。 + LOOKUP_LDAP: LDAP 协议前缀。 + LOOKUP_LDAPS: LDAPS 协议前缀。 + YES: 系统默认值 "是" 的标识。 + NO: 系统默认值 "否" 的标识。 + DEPT_NORMAL: 部门状态,表示正常。 + DEPT_DISABLE: 部门状态,表示停用。 + UNIQUE: 标识检查结果为唯一。 + NOT_UNIQUE: 标识检查结果为不唯一。 + """ + + WWW = 'www.' + """`www.` 主域的前缀""" + + HTTP = 'http://' + """`http://` 协议前缀""" + + HTTPS = 'https://' + """`https://` 协议前缀""" + + LOOKUP_RMI = 'rmi:' + """RMI(远程方法调用)协议前缀""" + + LOOKUP_LDAP = 'ldap:' + """LDAP 协议前缀""" + + LOOKUP_LDAPS = 'ldaps:' + """LDAPS 协议前缀""" + + YES = 'Y' + """系统默认值 "是" 的标识""" + + NO = 'N' + """系统默认值 "否" 的标识""" + + DEPT_NORMAL = '0' + """部门状态,表示正常""" + + DEPT_DISABLE = '1' + """部门状态,表示停用""" + + UNIQUE = True + """标识检查结果为唯一""" + + NOT_UNIQUE = False + """标识检查结果为不唯一""" + + +class HttpStatusConstant: + """ + 定义 HTTP 状态码的常量,描述不同操作的响应结果。 + + 属性: + SUCCESS: 表示操作成功,HTTP 状态码 200。 + CREATED: 表示资源已成功创建,HTTP 状态码 201。 + ACCEPTED: 表示请求已被接受,HTTP 状态码 202。 + NO_CONTENT: 表示操作成功但无内容返回,HTTP 状态码 204。 + MOVED_PERM: 表示资源已被永久移除,HTTP 状态码 301。 + SEE_OTHER: 表示重定向到其他资源,HTTP 状态码 303。 + NOT_MODIFIED: 表示资源未被修改,HTTP 状态码 304。 + BAD_REQUEST: 参数错误,HTTP 状态码 400。 + UNAUTHORIZED: 表示未授权,HTTP 状态码 401。 + FORBIDDEN: 表示禁止访问,HTTP 状态码 403。 + NOT_FOUND: 表示资源或服务未找到,HTTP 状态码 404。 + BAD_METHOD: 不允许的 HTTP 方法,HTTP 状态码 405。 + CONFLICT: 表示资源冲突,HTTP 状态码 409。 + UNSUPPORTED_TYPE: 不支持的数据或媒体类型,HTTP 状态码 415。 + ERROR: 表示系统内部错误,HTTP 状态码 500。 + NOT_IMPLEMENTED: 接口未实现,HTTP 状态码 501。 + WARN: 系统警告消息,自定义状态码 601。 + """ + + SUCCESS = 200 + """表示操作成功,HTTP 状态码 200。""" + + CREATED = 201 + """表示资源已成功创建,HTTP 状态码 201。""" + + ACCEPTED = 202 + """表示请求已被接受,HTTP 状态码 202。""" + + NO_CONTENT = 204 + """表示操作成功但无内容返回,HTTP 状态码 204。""" + + MOVED_PERM = 301 + """表示资源已被永久移除,HTTP 状态码 301。""" + + SEE_OTHER = 303 + """表示重定向到其他资源,HTTP 状态码 303。""" + + NOT_MODIFIED = 304 + """表示资源未被修改,HTTP 状态码 304。""" + + BAD_REQUEST = 400 + """参数错误,HTTP 状态码 400。""" + + UNAUTHORIZED = 401 + """表示未授权,HTTP 状态码 401。""" + + FORBIDDEN = 403 + """表示禁止访问,HTTP 状态码 403。""" + + NOT_FOUND = 404 + """表示资源或服务未找到,HTTP 状态码 404。""" + + BAD_METHOD = 405 + """不允许的 HTTP 方法,HTTP 状态码 405。""" + + CONFLICT = 409 + """表示资源冲突,HTTP 状态码 409。""" + + UNSUPPORTED_TYPE = 415 + """不支持的数据或媒体类型,HTTP 状态码 415。""" + + ERROR = 500 + """表示系统内部错误,HTTP 状态码 500。""" + + NOT_IMPLEMENTED = 501 + """接口未实现,HTTP 状态码 501。""" + + WARN = 601 + """系统警告消息,自定义状态码 601。""" + + +class JobConstant: + """ + 定时任务相关的常量,限制和规范任务模块的调用。 + + 属性: + JOB_ERROR_LIST: 禁止调用的模块及违规字符串列表,包含敏感或不安全操作。 + JOB_WHITE_LIST: 允许调用的模块列表,用于指定合法模块。 + """ + + JOB_ERROR_LIST = [ + 'app', 'config', 'exceptions', 'import ', 'middlewares', + 'module_admin', 'open(', 'os.', 'server', 'sub_applications', + 'subprocess.', 'sys.', 'utils', 'while ', '__import__', '"', "'", + ',', '?', ':', ';', '/', '|', '+', '-', '=', '~', '!', '#', '$', + '%', '^', '&', '*', '<', '>', '(', ')', '[', ']', '{', '}', ' ' + ] + """禁止调用的模块及违规字符串列表,包含敏感或不安全操作。""" + + JOB_WHITE_LIST = ['module_task'] + """允许调用的模块列表,用于指定合法模块。""" + + +class RedisKeyConfig(Enum): + """ + 定义 Redis 键的常量,用于缓存和存储数据。 + """ + + @property + def key(self): + """ + 获取 Redis 键名 + :return: 键名字符串 + """ + return self.value.get('key') + + @property + def remark(self): + """ + 获取键名备注信息 + :return: 备注信息字符串 + """ + return self.value.get('remark') + + ACCESS_TOKEN = {'key': 'access_token', 'remark': '登录令牌信息'} + """登录令牌信息,存储用户的访问令牌。""" + USER_INFO = {'key': 'user_info', 'remark': '用户信息'} + """用户信息,存储用户的详细信息。""" + USER_ROUTES = {'key': 'user_routes', 'remark': '用户路由信息'} + """用户路由信息,存储用户的路由信息。""" + CAPTCHA_CODES = {'key': 'captcha_codes', 'remark': '图片验证码'} + """图片验证码,存储验证码及其校验信息。""" + CAPTCHA_TYPES = {'key': 'captcha_types', 'remark': '图片验证码类型'} + """图片验证码类型,存储验证码类型及其配置信息。""" + EMAIL_CODES = {'key': 'email_codes', 'remark': '邮箱验证码'} + """邮箱验证码,存储邮箱验证码及其校验信息。""" + TRANSLATION_INFO = {'key': 'translation_info', 'remark': '国际化信息'} + """用于存储国际化数据。""" + TRANSLATION_TYPES = {'key': 'translation_types', 'remark': '国际化类型'} + """国际化类型,存储国际化类型及其配置信息。""" diff --git a/config/database.py b/config/database.py new file mode 100644 index 0000000..94125a9 --- /dev/null +++ b/config/database.py @@ -0,0 +1,138 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 02:00 +# @UpdateTime : 2025/01/18 02:00 +# @Author : sonder +# @File : database.py +# @Software : PyCharm +# @Comment : 本程序 +import logging +import sys +from logging.handlers import RotatingFileHandler + +from tortoise import Tortoise + +from config.env import DataBaseConfig +from utils.log import logger, log_path_sql + + +async def init_db(): + """ + 异步初始化数据库连接。 + """ + # 在数据库连接 URL 中添加时区参数(东八区) + db_url = ( + f"mysql://{DataBaseConfig.db_username}:{DataBaseConfig.db_password}@" + f"{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}" + "?charset=utf8mb4" # 指定时区为东八区, + ) + + await Tortoise.init( + db_url=db_url, + modules={"models": ["models"]}, # 指向 models 目录, + timezone="Asia/Shanghai", + ) + + # 根据 db_echo 配置是否打印 SQL 查询日志 + if DataBaseConfig.db_echo: + logger.info("SQL 查询日志已启用") + await configure_tortoise_logging(enable_logging=True, log_level=DataBaseConfig.db_log_level) + else: + logger.info("SQL 查询日志已禁用") + # 禁用 SQL 查询日志 + logger.remove(log_path_sql) + + # 生成数据库表结构 + await Tortoise.generate_schemas() + logger.success("数据库连接成功!") + + +async def close_db(): + """ + 关闭数据库连接。 + """ + await Tortoise.close_connections() + logger.success("数据库连接关闭!") + + +async def configure_tortoise_logging(enable_logging: bool = True, log_level: int = logging.DEBUG): + """ + 异步配置 Tortoise ORM 日志输出。 + + :param enable_logging: 是否启用日志输出 + :param log_level: 日志输出级别,默认为 DEBUG + """ + aiomysql_logger = logging.getLogger("aiomysql") + tortoise_logger = logging.getLogger("tortoise") + + # 清除之前的处理器,避免重复添加 + if tortoise_logger.hasHandlers(): + tortoise_logger.handlers.clear() + + if aiomysql_logger.hasHandlers(): + aiomysql_logger.handlers.clear() + + if enable_logging: + # 设置日志格式 + fmt = logging.Formatter( + fmt="%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + # 创建控制台处理器(输出到控制台) + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(log_level) + console_handler.setFormatter(fmt) + + # 创建文件处理器(输出到文件) + file_handler = RotatingFileHandler( + filename=log_path_sql, + maxBytes=50 * 1024 * 1024, # 日志文件大小达到 50MB 时轮换 + backupCount=5, # 保留 5 个旧日志文件 + encoding="utf-8", + ) + file_handler.setLevel(log_level) + file_handler.setFormatter(fmt) + + # 配置 tortoise 顶级日志记录器 + tortoise_logger.setLevel(log_level) + tortoise_logger.addHandler(console_handler) # 添加控制台处理器 + tortoise_logger.addHandler(file_handler) # 添加文件处理器 + + # 配置 aiomysql 日志记录器 + aiomysql_logger.setLevel(log_level) + aiomysql_logger.addHandler(console_handler) # 添加控制台处理器 + aiomysql_logger.addHandler(file_handler) # 添加文件处理器 + # 配置 SQL 查询日志记录器 + sql_logger = logging.getLogger("tortoise.db_client") + sql_logger.setLevel(log_level) + + class SQLResultLogger(logging.Handler): + async def emit(self, record): + # 只处理 SQL 查询相关的日志 + if "SELECT" in record.getMessage() or "INSERT" in record.getMessage() or "UPDATE" in record.getMessage() or "DELETE" in record.getMessage(): + # 输出 SQL 查询语句 + console_handler.emit(record) + file_handler.emit(record) + + # 异步获取并记录查询结果 + await self.log_query_result(record) + + async def log_query_result(self, record): + """ + 执行查询并返回结果。 + """ + try: + from tortoise import Tortoise + connection = Tortoise.get_connection("default") + result = await connection.execute_query_dict(record.getMessage()) + return result + except Exception as e: + return f"获取查询结果失败: {str(e)}" + + # 添加自定义 SQL 查询日志处理器 + sql_result_handler = SQLResultLogger() + sql_result_handler.setLevel(log_level) + sql_logger.addHandler(sql_result_handler) + else: + # 如果禁用日志,设置日志级别为 WARNING 以抑制大部分输出 + tortoise_logger.setLevel(logging.WARNING) diff --git a/config/env.py b/config/env.py new file mode 100644 index 0000000..092e084 --- /dev/null +++ b/config/env.py @@ -0,0 +1,539 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/17 22:18 +# @UpdateTime : 2025/01/17 22:18 +# @Author : sonder +# @File : env.py +# @Software : PyCharm +# @Comment : 本程序 +import argparse +import os +import sys +from functools import lru_cache + +from dotenv import load_dotenv +from pydantic_settings import BaseSettings + + +class AppSettings(BaseSettings): + """ + 应用配置类,用于管理 FastAPI 应用的基本设置。 + """ + + app_env: str = 'dev' + """ + 应用环境,默认为 'dev'。 + 可选值: + - 'dev': 开发环境 + - 'prod': 生产环境 + """ + + app_name: str = 'FasAPI' + """ + 应用名称,默认为 'FasAPI'。 + 用于标识当前应用的名称。 + """ + + app_root_path: str = '/dev-api' + """ + 应用的根路径,默认为 '/dev-api'。 + 用于设置 API 的根路径前缀。 + """ + + app_host: str = '0.0.0.0' + """ + 应用绑定的主机地址,默认为 '0.0.0.0'。 + - '0.0.0.0' 表示监听所有网络接口。 + - '127.0.0.1' 表示仅监听本地回环地址。 + """ + + app_port: int = 9090 + """ + 应用绑定的端口号,默认为 9090。 + 确保端口号未被其他服务占用。 + """ + + app_version: str = '1.0.0' + """ + 应用版本号,默认为 '1.0.0'。 + 用于标识当前应用的版本。 + """ + + app_reload: bool = True + """ + 是否启用自动重载,默认为 True。 + - True: 启用自动重载,适合开发环境。 + - False: 禁用自动重载,适合生产环境。 + """ + + app_ip_location_query: bool = True + """ + 是否启用 IP 地址地理位置查询,默认为 True。 + - True: 启用 IP 地址地理位置查询功能。 + - False: 禁用 IP 地址地理位置查询功能。 + """ + + app_same_time_login: bool = True + """ + 是否允许同一用户同时登录多个设备,默认为 True。 + - True: 允许同一用户同时登录多个设备。 + - False: 禁止同一用户同时登录多个设备。 + """ + + class Config: + env_file_encoding = "utf-8" # 指定文件编码 + + +class JwtSettings(BaseSettings): + """ + JWT 配置类,用于管理 JWT(JSON Web Token)相关的设置。 + """ + + jwt_secret_key: str = 'b01c66dc2c58dc6a0aabfe2144256be36226de378bf87f72c0c795dda67f4d55' + """ + JWT 签名密钥,默认为一个随机生成的字符串。 + - 用于加密和解密 JWT 令牌。 + - 生产环境中应使用更安全的密钥,并妥善保管。 + """ + + jwt_algorithm: str = 'HS256' + """ + JWT 签名算法,默认为 'HS256'。 + - 支持的算法包括: + - 'HS256': HMAC + SHA-256 + - 'HS384': HMAC + SHA-384 + - 'HS512': HMAC + SHA-512 + - 'RS256': RSA + SHA-256 + - 其他算法请参考 PyJWT 文档。 + """ + + jwt_salt: str = 'jwt_salt' + """ + JWT 盐值,默认为 'jwt_salt'。 + - 用于进一步增强 JWT 的安全性,防止彩虹表攻击。 + - 生产环境中应使用更复杂的盐值。 + """ + + jwt_expire_minutes: int = 1440 + """ + JWT 令牌的有效期(以分钟为单位),默认为 1440 分钟(24 小时)。 + - 超过有效期后,令牌将自动失效。 + - 可以根据需求调整有效期。 + """ + + jwt_redis_expire_minutes: int = 30 + """ + JWT 令牌在 Redis 中的缓存有效期(以分钟为单位),默认为 30 分钟。 + - 用于存储已签发的 JWT 令牌,以便快速验证和吊销。 + - 可以根据需求调整缓存有效期。 + """ + + class Config: + env_file_encoding = "utf-8" # 指定文件编码 + + +class DataBaseSettings(BaseSettings): + """ + 数据库配置类,用于管理数据库连接相关的设置。 + """ + + db_type: str = 'mysql' + """ + 数据库类型,默认为 'mysql'。 + - 支持的数据库类型包括: + - 'mysql': MySQL 数据库 + - 'postgresql': PostgreSQL 数据库 + - 'sqlite': SQLite 数据库 + """ + + db_host: str = '127.0.0.1' + """ + 数据库主机地址,默认为 '127.0.0.1'。 + - 如果数据库运行在本地,可以使用 'localhost' 或 '127.0.0.1'。 + - 如果数据库运行在远程服务器,请填写服务器的 IP 地址或域名。 + """ + + db_port: int = 3306 + """ + 数据库端口号,默认为 3306。 + - MySQL 默认端口号为 3306。 + - PostgreSQL 默认端口号为 5432。 + - 根据实际数据库类型和配置调整端口号。 + """ + + db_username: str = 'root' + """ + 数据库用户名,默认为 'root'。 + - 用于连接数据库的用户名。 + - 生产环境中应使用具有最小权限的用户。 + """ + + db_password: str = 'mysqlroot' + """ + 数据库密码,默认为 'mysqlroot'。 + - 用于连接数据库的密码。 + - 生产环境中应使用强密码,并妥善保管。 + """ + + db_database: str = 'fastapi' + """ + 数据库名称,默认为 'fastapi'。 + - 用于指定连接的数据库名称。 + - 确保数据库已创建并具有相应的权限。 + """ + + db_echo: bool = True + """ + 是否打印 SQL 语句,默认为 True。 + - True: 打印所有执行的 SQL 语句,适合开发和调试。 + - False: 不打印 SQL 语句,适合生产环境。 + """ + + db_max_overflow: int = 10 + """ + 连接池最大溢出连接数,默认为 10。 + - 当连接池中的连接数达到最大值时,允许额外创建的连接数。 + - 根据实际负载调整该值。 + """ + + db_log_level: int = 10 + """ + 数据库日志级别,默认为 10(DEBUG)。 + - 用于控制数据库日志的输出级别。 + - 10: DEBUG + - 20: INFO + - 30: WARNING + - 40: ERROR + - 50: CRITICAL + - 根据实际需求调整日志级别。 + """ + + db_pool_size: int = 50 + """ + 连接池大小,默认为 50。 + - 连接池中保持的最大连接数。 + - 根据实际并发需求调整该值。 + """ + + db_pool_recycle: int = 3600 + """ + 连接池回收时间(秒),默认为 3600 秒(1 小时)。 + - 超过该时间的连接会被回收并重新创建。 + - 用于避免数据库连接超时问题。 + """ + + db_pool_timeout: int = 30 + """ + 连接池超时时间(秒),默认为 30 秒。 + - 从连接池获取连接的超时时间。 + - 如果超时未获取到连接,会抛出异常。 + """ + + class Config: + env_file_encoding = "utf-8" # 指定文件编码 + + +class RedisSettings(BaseSettings): + """ + Redis 配置类,用于管理 Redis 连接相关的设置。 + """ + + redis_host: str = '127.0.0.1' + """ + Redis 主机地址,默认为 '127.0.0.1'。 + - 如果 Redis 运行在本地,可以使用 'localhost' 或 '127.0.0.1'。 + - 如果 Redis 运行在远程服务器,请填写服务器的 IP 地址或域名。 + """ + + redis_port: int = 6379 + """ + Redis 端口号,默认为 6379。 + - Redis 默认端口号为 6379。 + - 根据实际 Redis 配置调整端口号。 + """ + + redis_username: str = '' + """ + Redis 用户名,默认为空。 + - 如果 Redis 启用了身份验证,请填写用户名。 + - 如果未启用身份验证,可以留空。 + """ + + redis_password: str = '' + """ + Redis 密码,默认为空。 + - 如果 Redis 启用了身份验证,请填写密码。 + - 如果未启用身份验证,可以留空。 + """ + + redis_database: int = 2 + """ + Redis 数据库索引,默认为 2。 + - Redis 支持多个数据库,索引从 0 开始。 + - 根据实际需求选择合适的数据库索引。 + """ + + class Config: + env_file_encoding = "utf-8" # 指定文件编码 + + +class UploadSettings: + """ + 上传配置类,用于管理文件上传和下载的相关设置。 + """ + + UPLOAD_PREFIX = '/profile' + """ + 文件上传的 URL 前缀,默认为 '/profile'。 + - 用于在访问上传文件时添加到 URL 的前缀。 + - 例如:`/profile/example.jpg`。 + """ + + UPLOAD_PATH = 'data/upload_path' + """ + 文件上传的存储路径,默认为 'data/upload_path'。 + - 上传的文件将存储在此目录中。 + - 如果目录不存在,会自动创建。 + """ + + UPLOAD_MACHINE = 'A' + """ + 上传机器的标识,默认为 'A'。 + - 用于区分不同的上传机器或节点。 + - 在多机部署时可以使用此字段。 + """ + + DEFAULT_ALLOWED_EXTENSION = [ + # 图片 + 'bmp', + 'gif', + 'jpg', + 'jpeg', + 'png', + # word excel powerpoint + 'doc', + 'docx', + 'xls', + 'xlsx', + 'ppt', + 'pptx', + 'html', + 'htm', + 'txt', + # 压缩文件 + 'rar', + 'zip', + 'gz', + 'bz2', + # 视频格式 + 'mp4', + 'avi', + 'rmvb', + # pdf + 'pdf', + ] + """ + 默认允许上传的文件扩展名列表。 + - 包含常见的图片、文档、压缩文件、视频和 PDF 格式。 + - 可以根据需求扩展或修改此列表。 + """ + + DOWNLOAD_PATH = 'data/download_path' + """ + 文件下载的存储路径,默认为 'data/download_path'。 + - 下载的文件将存储在此目录中。 + - 如果目录不存在,会自动创建。 + """ + + def __init__(self): + """ + 初始化方法,确保上传和下载路径存在。 + - 如果路径不存在,会自动创建。 + """ + if not os.path.exists(self.UPLOAD_PATH): + os.makedirs(self.UPLOAD_PATH) + if not os.path.exists(self.DOWNLOAD_PATH): + os.makedirs(self.DOWNLOAD_PATH) + + +class EmailSettings(BaseSettings): + """ + 邮件配置类,用于管理邮件发送相关的设置。 + """ + email_username: str = "" + """ + 邮件发送者的用户名,默认为空。 + """ + email_password: str = "" + """ + 邮件发送者的密码,默认为空。 + """ + email_host: str = "smtp.qq.com" + """ + 邮件服务器地址,默认为 "smtp.qq.com"。 + """ + email_port: int = 465 + """ + 邮件服务器端口,默认为 465。 + """ + + class Config: + env_file_encoding = "utf-8" # 指定文件编码 + + +class MapSettings(BaseSettings): + """ + 地图配置类,用于管理地图相关的设置。 + """ + ak: str = "" + """ + 控制台-应用管理-创建应用后获取的AK + """ + sk: str = "" + """ + 控制台-应用管理-创建应用时,校验方式选择sn校验后生成的SK + """ + + +class CachePathConfig: + """ + 缓存目录配置 + """ + + PATH = os.path.join(os.path.abspath(os.getcwd()), 'caches') + PATHSTR = 'caches' + + +class GetConfig: + """ + 获取配置类,用于集中管理和获取应用的所有配置。 + """ + + def __init__(self): + """ + 初始化方法,解析命令行参数并加载环境变量。 + """ + self.parse_cli_args() + + @lru_cache() + def get_app_config(self) -> BaseSettings: + """ + 获取应用配置。 + - 返回 AppSettings 的实例。 + - 使用 lru_cache 缓存结果,避免重复实例化。 + """ + # 实例化应用配置模型 + return AppSettings() + + @lru_cache() + def get_jwt_config(self) -> BaseSettings: + """ + 获取 JWT 配置。 + - 返回 JwtSettings 的实例。 + - 使用 lru_cache 缓存结果,避免重复实例化。 + """ + # 实例化 JWT 配置模型 + return JwtSettings() + + @lru_cache() + def get_database_config(self) -> BaseSettings: + """ + 获取数据库配置。 + - 返回 DataBaseSettings 的实例。 + - 使用 lru_cache 缓存结果,避免重复实例化。 + """ + # 实例化数据库配置模型 + return DataBaseSettings() + + @lru_cache() + def get_redis_config(self) -> BaseSettings: + """ + 获取 Redis 配置。 + - 返回 RedisSettings 的实例。 + - 使用 lru_cache 缓存结果,避免重复实例化。 + """ + # 实例化 Redis 配置模型 + return RedisSettings() + + @lru_cache() + def get_upload_config(self) -> 'UploadSettings': + """ + 获取上传配置。 + - 返回 UploadSettings 的实例。 + - 使用 lru_cache 缓存结果,避免重复实例化。 + """ + # 实例化上传配置 + return UploadSettings() + + @lru_cache() + def get_email_config(self) -> 'EmailSettings': + """ + 获取邮件配置。 + - 返回 EmailSettings 的实例。 + - 使用 lru_cache 缓存结果,避免重复实例化。 + """ + # 实例化邮件配置 + return EmailSettings() + @lru_cache() + def get_map_config(self) -> 'MapSettings': + """ + 获取地图配置。 + - 返回 MapSettings 的实例。 + - 使用 lru_cache 缓存结果,避免重复实例化。 + """ + # 实例化地图配置 + return MapSettings() + + @staticmethod + def parse_cli_args(): + """ + 解析命令行参数。 + - 如果使用 uvicorn 启动,命令行参数无法自定义。 + - 否则,使用 argparse 解析自定义命令行参数。 + - 根据命令行参数设置环境变量,并加载对应的 .env 文件。 + """ + if 'uvicorn' in sys.argv[0]: + # 使用 uvicorn 启动时,命令行参数需要按照 uvicorn 的文档进行配置,无法自定义参数 + pass + else: + # 使用 argparse 定义命令行参数 + parser = argparse.ArgumentParser(description='命令行参数') + parser.add_argument('--env', type=str, default='', help='运行环境') + # 解析命令行参数 + args = parser.parse_args() + # 设置环境变量,如果未设置命令行参数,默认 APP_ENV 为 dev + os.environ['APP_ENV'] = args.env if args.env else 'dev' + + # 读取运行环境 + run_env = os.environ.get('APP_ENV', '') + # 运行环境未指定时默认加载 .env.dev + env_file = '.env.dev' + # 运行环境不为空时按命令行参数加载对应 .env 文件 + if run_env != '': + env_file = f'.env.{run_env}' + # 加载配置 + load_dotenv(env_file) + + +# 实例化获取配置类 +get_config = GetConfig() + +# 应用配置 +AppConfig = get_config.get_app_config() + +# JWT 配置 +JwtConfig = get_config.get_jwt_config() + +# 数据库配置 +DataBaseConfig = get_config.get_database_config() + +# Redis 配置 +RedisConfig = get_config.get_redis_config() + +# 上传配置 +UploadConfig = get_config.get_upload_config() + +# 邮件配置 +EmailConfig = get_config.get_email_config() + +# 地图配置 +MapConfig = get_config.get_map_config() diff --git a/config/get_redis.py b/config/get_redis.py new file mode 100644 index 0000000..e3e27b3 --- /dev/null +++ b/config/get_redis.py @@ -0,0 +1,68 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 02:41 +# @UpdateTime : 2025/01/18 02:41 +# @Author : sonder +# @File : get_redis.py +# @Software : PyCharm +# @Comment : 本程序 + +from redis import asyncio as aioredis +from redis.exceptions import AuthenticationError, TimeoutError, RedisError + +from config.env import RedisConfig +from utils.log import logger + + +class Redis: + """ + Redis工具类 + 提供与 Redis 交互的常用方法,包括连接管理和缓存初始化。 + """ + + @classmethod + async def create_redis_pool(cls) -> aioredis.Redis: + """ + 应用启动时初始化 Redis 连接池。 + + 该方法通过 `aioredis` 创建一个 Redis 连接池,并验证连接是否成功。 + 如果连接失败,会捕获异常并记录错误日志。 + + :return: Redis 连接对象 + """ + logger.info('开始连接 Redis...') + redis = await aioredis.from_url( + url=f'redis://{RedisConfig.redis_host}', + port=RedisConfig.redis_port, + username=RedisConfig.redis_username, + password=RedisConfig.redis_password, + db=RedisConfig.redis_database, + encoding='utf-8', # 默认编码为 UTF-8 + decode_responses=True, # 自动解码 Redis 响应 + ) + try: + # 测试 Redis 连接 + connection = await redis.ping() + if connection: + logger.info('Redis 连接成功') + else: + logger.error('Redis 连接失败') + except AuthenticationError as e: + logger.error(f'Redis 用户名或密码错误,详细错误信息:{e}') + except TimeoutError as e: + logger.error(f'Redis 连接超时,详细错误信息:{e}') + except RedisError as e: + logger.error(f'Redis 连接错误,详细错误信息:{e}') + return redis + + @classmethod + async def close_redis_pool(cls, app): + """ + 应用关闭时关闭 Redis 连接池。 + + 在应用生命周期结束时,清理 Redis 连接池以释放资源。 + + :param app: FastAPI 应用对象,用于访问全局 Redis 连接池。 + :return: None + """ + await app.state.redis.close() + logger.info('关闭 Redis 连接成功') diff --git a/controller/__init__.py b/controller/__init__.py new file mode 100644 index 0000000..d7c0557 --- /dev/null +++ b/controller/__init__.py @@ -0,0 +1,7 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/19 00:57 +# @UpdateTime : 2025/01/19 00:57 +# @Author : sonder +# @File : __init__.py.py +# @Software : PyCharm +# @Comment : 本程序 diff --git a/controller/login.py b/controller/login.py new file mode 100644 index 0000000..bf2dd3c --- /dev/null +++ b/controller/login.py @@ -0,0 +1,258 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/19 01:42 +# @UpdateTime : 2025/01/19 01:42 +# @Author : sonder +# @File : login.py +# @Software : PyCharm +# @Comment : 本程序 +import uuid +from datetime import timedelta, datetime +from typing import Optional, Union + +from fastapi import Form, Depends, Request +from fastapi.encoders import jsonable_encoder +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from jose import jwt +from jose.exceptions import JWEInvalidAuth, ExpiredSignatureError, JWEError + +from config.constant import RedisKeyConfig +from config.env import JwtConfig +from controller.query import QueryController +from exceptions.exception import AuthException +from models import LoginLog +from schemas.login import LoginParams +from utils.log import logger +from utils.password import Password + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl='login') + + +class CustomOAuth2PasswordRequestForm(OAuth2PasswordRequestForm): + """ + 自定义OAuth2PasswordRequestForm类,增加验证码及会话编号参数 + """ + + def __init__( + self, + grant_type: str = Form(default=None, regex='password'), + username: str = Form(..., description="用户账号"), + password: str = Form(..., description="用户密码"), + scope: str = Form(default=''), + client_id: Optional[str] = Form(default=None), + client_secret: Optional[str] = Form(default=None), + loginDays: Optional[int] = Form(default=1), + code: Optional[str] = Form(default=''), + uuid: Optional[str] = Form(default=''), + ): + super().__init__( + grant_type=grant_type, + username=username, + password=password, + scope=scope, + client_id=client_id, + client_secret=client_secret, + ) + self.code = code + self.uuid = uuid + self.loginDays = loginDays + + +class LoginController: + """ + 登录控制器 + """ + + @classmethod + async def login(cls, params: LoginParams): + """ + 登录 + :param params: + :return: + """ + result = await QueryController.get_user_by_username(params.username) + if result and await Password.verify_password(params.password, result.password): + userInfo = await QueryController.get_user_info(user_id=result.id.__str__()) + logger.success(f"用户 {params.username} 登录成功") + session_id = uuid.uuid4().__str__() + accessToken = await cls.create_token( + data={"user": jsonable_encoder(userInfo), "id": result.id.__str__(), "session_id": session_id}, + expires_delta=timedelta(minutes=params.loginDays * 24 * 60)) + expiresTime = (datetime.now() + timedelta(minutes=params.loginDays * 24 * 60)).timestamp() + refreshToken = await cls.create_token( + data={"user": jsonable_encoder(userInfo), "id": result.id.__str__(), "session_id": session_id}, + expires_delta=timedelta(minutes=(params.loginDays * 24 + 2) * 60)) + return {"status": True, "accessToken": accessToken, "refreshToken": refreshToken, + "userInfo": userInfo, + "expiresTime": expiresTime, "session_id": session_id, "expiresIn": params.loginDays * 24 * 60} + logger.error(f"用户 {params.username} 登录失败") + return {"status": False} + + @classmethod + async def create_token(cls, data: dict, expires_delta: Union[timedelta, None] = None) -> str: + """ + 创建token + :param data: 存储数据 + :param expires_delta: 过期时间 + :return: token + """ + to_copy = data.copy() + if expires_delta: + expire = datetime.now() + expires_delta + else: + expire = datetime.now() + timedelta(minutes=JwtConfig.jwt_expire_minutes) + to_copy.update({"exp": expire}) + return jwt.encode(claims=to_copy, key=JwtConfig.jwt_secret_key, algorithm=JwtConfig.jwt_algorithm) + + @classmethod + async def get_current_user(cls, request: Request = Request, token: str = Depends(oauth2_scheme)): + """ + 获取当前用户 + :param request: + :param token: + :return: + """ + try: + if token.startswith('Bearer'): + token = token.split(' ')[1] + payload = jwt.decode(token=token, key=JwtConfig.jwt_secret_key, algorithms=[JwtConfig.jwt_algorithm]) + user_id: str = payload.get("id", "") + session_id: str = payload.get('session_id', "") + if not user_id: + logger.warning('用户token不合法') + raise AuthException(data='', message='用户token不合法') + except (JWEInvalidAuth, ExpiredSignatureError, JWEError): + logger.warning('用户token已失效,请重新登录') + raise AuthException(data='', message='用户token已失效,请重新登录') + userInfo = await request.app.state.redis.get(f'{RedisKeyConfig.USER_INFO.key}:{user_id}') + if userInfo: + userInfo = eval(userInfo) + if not userInfo: + userInfo = await QueryController.get_user_info(user_id=user_id) + await request.app.state.redis.set(f'{RedisKeyConfig.USER_INFO.key}:{user_id}', + str(jsonable_encoder(userInfo)), + ex=timedelta(minutes=5)) + if not userInfo: + logger.warning('用户token不合法') + raise AuthException(data='', message='用户token不合法') + redis_token = await request.app.state.redis.get(f'{RedisKeyConfig.ACCESS_TOKEN.key}:{session_id}') + if not redis_token: + logger.warning('用户token已失效,请重新登录') + raise AuthException(data='', message='用户token已失效,请重新登录') + return userInfo + + @classmethod + async def logout(cls, request: Request = Request, token: str = Depends(oauth2_scheme)) -> bool: + """ + 登出 + """ + try: + if token.startswith('Bearer'): + token = token.split(' ')[1] + payload = jwt.decode(token=token, key=JwtConfig.jwt_secret_key, algorithms=[JwtConfig.jwt_algorithm]) + session_id: str = payload.get('session_id', "") + except (JWEInvalidAuth, ExpiredSignatureError, JWEError): + logger.warning('用户token已失效,请重新登录') + raise AuthException(data='', message='用户token已失效,请重新登录') + redis_token = await request.app.state.redis.get(f'{RedisKeyConfig.ACCESS_TOKEN.key}:{session_id}') + if redis_token == token: + await request.app.state.redis.delete(f'{RedisKeyConfig.ACCESS_TOKEN.key}:{session_id}') + return True + return False + + @classmethod + async def get_user_routes(cls, user_id: str) -> Union[list, None]: + """ + 获取用户路由 + """ + permissions = await QueryController.get_user_permissions(user_id=user_id) + for permission in permissions: + permission["id"] = str(permission["id"]) + permission["parentId"] = str(permission["parentId"]) if permission.get("parentId") else "" + permissions = await cls.find_complete_data(permissions) + return permissions + + @classmethod + async def find_node_recursive(cls, node_id: str, data: list) -> dict: + """ + 递归查找节点 + :param node_id: 节点ID + :param data: 数据 + """ + result = {} + data = list(filter(lambda x: x.get('type') == 0, data)) + for item in data: + if item["id"] == node_id: + children = [] + for child_item in data: + if child_item["parentId"] == node_id: + child_node = await cls.find_node_recursive(child_item["id"], data) + if child_node: + children.append(child_node) + result = { + "name": item["name"], + "path": item["path"], + "meta": { + "title": item["title"], + "rank": item["rank"], + "icon": item["icon"], + "permissions": [item["auths"]], + }, + "children": children + } + if item["component"]: + result["component"] = item["component"].replace(".vue", "").replace(".ts", "").replace(".tsx", + "").replace( + ".js", "").replace(".jsx", "").strip() + if item["redirect"]: + result["redirect"] = item["redirect"] + if result["name"] == "": + result.pop("name") + if result["children"] == []: + result.pop("children") + break + return result + + @classmethod + async def find_complete_data(cls, data: list) -> list: + """ + 查找完整数据 + :param data: 数据 + """ + complete_data = [] + root_ids = [item["id"] for item in data if not item["parentId"]] + for root_id in root_ids: + complete_data.append(await cls.find_node_recursive(root_id, data)) + return complete_data + + @classmethod + async def get_online_user(cls, request: Request) -> list: + """ + 获取在线用户 + """ + access_token_keys = await request.app.state.redis.keys(f'{RedisKeyConfig.ACCESS_TOKEN.key}*') + if not access_token_keys: + access_token_keys = [] + access_token_values_list = [await request.app.state.redis.get(key) for key in access_token_keys] + online_info_list = [] + for item in access_token_values_list: + payload = jwt.decode(item, JwtConfig.jwt_secret_key, algorithms=[JwtConfig.jwt_algorithm]) + session_id = payload.get("session_id") + result = await LoginLog.get_or_none(session_id=session_id).values( + id="id", + user_id="user__id", + username="user__username", + user_nickname="user__nickname", + department_id="user__department__id", + department_name="user__department__name", + login_ip="login_ip", + login_location="login_location", + browser="browser", + os="os", + status="status", + login_time="login_time", + session_id="session_id", + create_time="create_time", + update_time="update_time" + ) + online_info_list.append(result) + return online_info_list diff --git a/controller/query.py b/controller/query.py new file mode 100644 index 0000000..0c04ae0 --- /dev/null +++ b/controller/query.py @@ -0,0 +1,132 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/23 21:22 +# @UpdateTime : 2025/01/23 21:22 +# @Author : sonder +# @File : query.py +# @Software : PyCharm +# @Comment : 本程序 +from typing import Union + +from tortoise.expressions import Q + +from models import User, UserRole, RolePermission +from utils.common import filterKeyValues + + +class QueryController: + """ + 数据库查询控制器 + """ + + @classmethod + async def get_user_by_username(cls, username: str) -> Union[User, None]: + """ + 根据用户名获取用户信息 + :param username:用户名|手机号|邮箱 + :return: + """ + return await User.get_or_none(Q(username=username) | Q(email=username) | Q(phone=username), del_flag=1) + + @classmethod + async def get_user_by_id(cls, user_id: str) -> Union[User, None]: + """ + :param user_id: + :return: + """ + return await User.get_or_none(id=user_id, del_flag=1) + + @classmethod + async def get_user_info(cls, user_id: str) -> Union[dict, None]: + """ + :param user_id:用户ID + """ + # 获取用户信息 + userInfo = await User.get_or_none(id=user_id, del_flag=1).values( + id="id", + username="username", + phone="phone", + email="email", + avatar="avatar", + nickname="nickname", + gender="gender", + status="status", + create_time="create_time", + update_time="update_time", + department_id="department__id", + department_name="department__name" + ) + # 获取用户角色 + userRoles = await UserRole.filter(user_id=user_id, del_flag=1).values( + role_id="role__id", + role_name="role__name", + role_code="role__code" + ) + # 获取用户角色标识 + userRole = await filterKeyValues(userRoles, "role_code") + # 获取用户角色ID + userRoleIds = await filterKeyValues(userRoles, "role_id") + # 根据用户角色ID获取用户权限 + permissions = [] + for item in userRoleIds: + permission = await RolePermission.filter(role_id=item, del_flag=1).values( + permission_id="permission__id", + permission_name="permission__name", + permission_auths="permission__auths" + ) + permissions.extend(permission) + permissions = await filterKeyValues(permissions, "permission_auths") + permissions = list(set(permissions)) + userInfo["roles"] = userRole + userInfo["permissions"] = permissions + return userInfo + + @classmethod + async def register_user_before(cls, username: str, phone: str, email: str) -> Union[User, None]: + """ + 注册用户前,检查用户名、手机号、邮箱是否已存在 + :param phone: + :param username: + :param email: + :return: + """ + return await User.get_or_none(Q(username=username) | Q(email=email) | Q(phone=phone), del_flag=1) + + @classmethod + async def get_user_permissions(cls, user_id: str) -> Union[list, None]: + """ + 获取用户权限 + """ + # 获取用户角色 + userRoles = await UserRole.filter(user_id=user_id, del_flag=1).values( + role_id="role__id", + role_name="role__name", + role_code="role__code" + ) + # 获取用户角色ID + userRoleIds = await filterKeyValues(userRoles, "role_id") + # 根据用户角色ID获取用户权限 + permissions = [] + for item in userRoleIds: + permission = await RolePermission.filter(role_id=item, del_flag=1).values( + id="permission__id", + parentId="permission__parent_id", + name="permission__name", + title="permission__title", + type="permission__menu_type", + path="permission__path", + component="permission__component", + redirect="permission__redirect", + rank="permission__rank", + icon="permission__icon", + extraIcon="permission__extra_icon", + enterTransition="permission__enter_transition", + leaveTransition="permission__leave_transition", + activePath="permission__active_path", + auths="permission__auths", + keepAlive="permission__keep_alive", + hiddenTag="permission__hidden_tag", + showLink="permission__show_link", + showParent="permission__show_parent" + ) + permissions.extend(permission) + return permissions diff --git a/exceptions/__init__.py b/exceptions/__init__.py new file mode 100644 index 0000000..a084028 --- /dev/null +++ b/exceptions/__init__.py @@ -0,0 +1,7 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 01:58 +# @UpdateTime : 2025/01/18 01:58 +# @Author : sonder +# @File : __init__.py.py +# @Software : PyCharm +# @Comment : 本程序 diff --git a/exceptions/exception.py b/exceptions/exception.py new file mode 100644 index 0000000..47d318c --- /dev/null +++ b/exceptions/exception.py @@ -0,0 +1,162 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 02:21 +# @UpdateTime : 2025/01/18 02:21 +# @Author : sonder +# @File : exception.py +# @Software : PyCharm +# @Comment : 本程序 +from typing import Optional, Any + +from fastapi.responses import JSONResponse + +from utils.response import Response + + +class LoginException(Exception): + """ + 自定义登录异常。 + - 用于处理登录相关的异常情况。 + """ + + def __init__(self, message: str = "登录失败", data: Optional[Any] = None): + """ + 初始化登录异常。 + + :param message: 异常消息,默认为 "登录失败" + :param data: 异常相关的数据 + """ + self.message = message + self.data = data + + def to_response(self) -> JSONResponse: + """ + 将异常转换为统一的响应格式。 + + :return: JSONResponse 对象 + """ + return Response.failure(msg=self.message, data=self.data) + + +class AuthException(Exception): + """ + 自定义令牌异常。 + - 用于处理身份验证相关的异常情况。 + """ + + def __init__(self, message: str = "身份验证失败", data: Optional[Any] = None): + """ + 初始化令牌异常。 + + :param message: 异常消息,默认为 "身份验证失败" + :param data: 异常相关的数据 + """ + self.message = message + self.data = data + + def to_response(self) -> JSONResponse: + """ + 将异常转换为统一的响应格式。 + + :return: JSONResponse 对象 + """ + return Response.unauthorized(msg=self.message, data=self.data) + + +class PermissionException(Exception): + """ + 自定义权限异常。 + - 用于处理权限相关的异常情况。 + """ + + def __init__(self, message: str = "权限不足", data: Optional[Any] = None): + """ + 初始化权限异常。 + + :param message: 异常消息,默认为 "权限不足" + :param data: 异常相关的数据 + """ + self.message = message + self.data = data + + def to_response(self) -> JSONResponse: + """ + 将异常转换为统一的响应格式。 + + :return: JSONResponse 对象 + """ + return Response.forbidden(msg=self.message, data=self.data) + + +class ServiceException(Exception): + """ + 自定义服务异常。 + - 用于处理服务层逻辑相关的异常情况。 + """ + + def __init__(self, message: str = "服务异常", data: Optional[Any] = None): + """ + 初始化服务异常。 + + :param message: 异常消息,默认为 "服务异常" + :param data: 异常相关的数据 + """ + self.message = message + self.data = data + + def to_response(self) -> JSONResponse: + """ + 将异常转换为统一的响应格式。 + + :return: JSONResponse 对象 + """ + return Response.error(msg=self.message, data=self.data) + + +class ServiceWarning(Exception): + """ + 自定义服务警告。 + - 用于处理服务层逻辑中的警告情况(非致命错误)。 + """ + + def __init__(self, message: str = "服务警告", data: Optional[Any] = None): + """ + 初始化服务警告。 + + :param message: 警告消息,默认为 "服务警告" + :param data: 警告相关的数据 + """ + self.message = message + self.data = data + + def to_response(self) -> JSONResponse: + """ + 将警告转换为统一的响应格式。 + + :return: JSONResponse 对象 + """ + return Response.failure(msg=self.message, data=self.data) + + +class ModelValidatorException(Exception): + """ + 自定义模型校验异常。 + - 用于处理数据模型校验失败的异常情况。 + """ + + def __init__(self, message: str = "数据校验失败", data: Optional[Any] = None): + """ + 初始化模型校验异常。 + + :param message: 异常消息,默认为 "数据校验失败" + :param data: 异常相关的数据 + """ + self.message = message + self.data = data + + def to_response(self) -> JSONResponse: + """ + 将异常转换为统一的响应格式。 + + :return: JSONResponse 对象 + """ + return Response.failure(msg=self.message, data=self.data) diff --git a/exceptions/handle.py b/exceptions/handle.py new file mode 100644 index 0000000..8128c14 --- /dev/null +++ b/exceptions/handle.py @@ -0,0 +1,134 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 02:24 +# @UpdateTime : 2025/01/18 02:24 +# @Author : sonder +# @File : handle.py +# @Software : PyCharm +# @Comment : 本程序 +from fastapi import FastAPI, Request +from fastapi.exceptions import HTTPException +from pydantic_validation_decorator import FieldValidationError +from starlette.responses import JSONResponse + +from exceptions.exception import ( + AuthException, + LoginException, + ModelValidatorException, + PermissionException, + ServiceException, + ServiceWarning, +) +from utils.log import logger +from utils.response import Response + + +def handle_exception(app: FastAPI): + """ + 全局异常处理拦截器。 + - 捕获并处理所有异常,返回统一的接口响应格式。 + """ + + @app.exception_handler(AuthException) + async def auth_exception_handler(request: Request, exc: AuthException): + """ + 处理自定义身份验证异常(AuthException)。 + - 返回 401 未授权响应。 + """ + logger.warning(f"身份验证异常: {exc.message}") + return Response.unauthorized(data=exc.data, msg=exc.message) + + @app.exception_handler(LoginException) + async def login_exception_handler(request: Request, exc: LoginException): + """ + 处理自定义登录异常(LoginException)。 + - 返回 400 失败响应。 + """ + logger.warning(f"登录异常: {exc.message}") + return Response.failure(data=exc.data, msg=exc.message) + + @app.exception_handler(ModelValidatorException) + async def model_validator_exception_handler(request: Request, exc: ModelValidatorException): + """ + 处理自定义模型校验异常(ModelValidatorException)。 + - 返回 400 失败响应。 + """ + logger.warning(f"模型校验异常: {exc.message}") + return Response.failure(data=exc.data, msg=exc.message) + + @app.exception_handler(FieldValidationError) + async def field_validation_error_handler(request: Request, exc: FieldValidationError): + """ + 处理字段校验异常(FieldValidationError)。 + - 返回 400 失败响应。 + """ + logger.warning(f"字段校验异常: {exc.message}") + return Response.failure(msg=exc.message) + + @app.exception_handler(PermissionException) + async def permission_exception_handler(request: Request, exc: PermissionException): + """ + 处理自定义权限异常(PermissionException)。 + - 返回 403 未授权响应。 + """ + logger.warning(f"权限异常: {exc.message}") + return Response.forbidden(data=exc.data, msg=exc.message) + + @app.exception_handler(ServiceException) + async def service_exception_handler(request: Request, exc: ServiceException): + """ + 处理自定义服务异常(ServiceException)。 + - 返回 500 错误响应。 + """ + logger.error(f"服务异常: {exc.message}") + return Response.error(data=exc.data, msg=exc.message) + + @app.exception_handler(ServiceWarning) + async def service_warning_handler(request: Request, exc: ServiceWarning): + """ + 处理自定义服务警告(ServiceWarning)。 + - 返回 400 失败响应。 + """ + logger.warning(f"服务警告: {exc.message}") + return Response.failure(data=exc.data, msg=exc.message) + + @app.exception_handler(HTTPException) + async def http_exception_handler(request: Request, exc: HTTPException): + """ + 处理 FastAPI 的 HTTP 异常。 + - 返回统一的错误响应格式。 + """ + logger.warning(f"HTTP 异常: {exc.detail}") + return Response.failure(msg=exc.detail) + + @app.exception_handler(Exception) + async def exception_handler(request: Request, exc: Exception): + """ + 处理其他未捕获的异常。 + - 返回 500 错误响应。 + """ + logger.exception(f"未捕获的异常: {str(exc)}") + return Response.error(msg="服务器内部错误") + + @app.exception_handler(404) + async def not_found_exception_handler(request: Request, exc: HTTPException): + """ + 处理 404 未找到资源异常。 + - 返回 404 失败响应。 + """ + logger.warning(f"404 异常: {request.url} 未找到") + return JSONResponse( + content={"code": 404, "msg": "无效路径!", "data": None}, + status_code=404, + ) + + @app.exception_handler(405) + async def method_not_allowed_handler(request: Request, exc: HTTPException): + """ + 处理 405 方法不允许异常。 + - 返回 405 失败响应。 + """ + logger.warning(f"405 异常: {request.method} 方法不允许") + return JSONResponse( + status_code=405, + content={"code": 405, "msg": "请求方法错误", "data": None}, + ) diff --git a/middlewares/__init__.py b/middlewares/__init__.py new file mode 100644 index 0000000..4970cc1 --- /dev/null +++ b/middlewares/__init__.py @@ -0,0 +1,7 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 02:44 +# @UpdateTime : 2025/01/18 02:44 +# @Author : sonder +# @File : __init__.py.py +# @Software : PyCharm +# @Comment : 本程序 diff --git a/middlewares/cors.py b/middlewares/cors.py new file mode 100644 index 0000000..6cf32c3 --- /dev/null +++ b/middlewares/cors.py @@ -0,0 +1,31 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 02:44 +# @UpdateTime : 2025/01/18 02:44 +# @Author : sonder +# @File : cors.py +# @Software : PyCharm +# @Comment : 本程序 +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + + +def add_cors_middleware(app: FastAPI): + """ + 添加跨域中间件 + + :param app: FastAPI对象 + :return: + """ + # 前端页面url + origins = [ + "*" + ] + + # 后台api允许跨域 + app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=['*'], + allow_headers=['*'], + ) diff --git a/middlewares/gzip.py b/middlewares/gzip.py new file mode 100644 index 0000000..9d2e864 --- /dev/null +++ b/middlewares/gzip.py @@ -0,0 +1,19 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 02:45 +# @UpdateTime : 2025/01/18 02:45 +# @Author : sonder +# @File : gzip.py +# @Software : PyCharm +# @Comment : 本程序 +from fastapi import FastAPI +from starlette.middleware.gzip import GZipMiddleware + + +def add_gzip_middleware(app: FastAPI): + """ + 添加gzip压缩中间件 + + :param app: FastAPI对象 + :return: + """ + app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=9) diff --git a/middlewares/handle.py b/middlewares/handle.py new file mode 100644 index 0000000..185bc6b --- /dev/null +++ b/middlewares/handle.py @@ -0,0 +1,21 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 02:45 +# @UpdateTime : 2025/01/18 02:45 +# @Author : sonder +# @File : handle.py +# @Software : PyCharm +# @Comment : 本程序 +from fastapi import FastAPI + +from middlewares.cors import add_cors_middleware +from middlewares.gzip import add_gzip_middleware + + +def handle_middleware(app: FastAPI): + """ + 全局中间件处理 + """ + # 加载跨域中间件 + add_cors_middleware(app) + # 加载gzip压缩中间件 + add_gzip_middleware(app) diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..5c53b61 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,30 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 02:42 +# @UpdateTime : 2025/01/18 02:42 +# @Author : sonder +# @File : __init__.py.py +# @Software : PyCharm +# @Comment : 本程序 + +from models.department import Department, DepartmentRole +from models.file import File +from models.log import LoginLog, OperationLog +from models.permission import Permission +from models.role import Role, RolePermission +from models.user import User, UserRole +from models.i18n import I18n,Locale + +__all__ = [ + 'Department', + 'DepartmentRole', + 'File', + 'LoginLog', + 'OperationLog', + 'Permission', + 'Role', + 'RolePermission', + 'User', + 'UserRole', + 'I18n', + 'Locale' +] diff --git a/models/common.py b/models/common.py new file mode 100644 index 0000000..3b3a7ff --- /dev/null +++ b/models/common.py @@ -0,0 +1,59 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 02:43 +# @UpdateTime : 2025/01/18 02:43 +# @Author : sonder +# @File : common.py +# @Software : PyCharm +# @Comment : 本程序 + +from tortoise import fields, models + + +class BaseModel(models.Model): + """ + 抽象模型,用于定义数据表的公共字段。 + """ + + id = fields.UUIDField(pk=True, description="主键", autoincrement=True) + """ + 自增 UUID,作为主键。 + - 使用 UUIDField 生成唯一标识符。 + """ + + del_flag = fields.SmallIntField(default=1, description="删除标志 1存在 0删除") + """ + 删除标志。 + - 1 代表存在,0 代表删除。 + - 默认为 1。 + """ + + create_by = fields.CharField(max_length=255, default='', description="创建者") + """ + 创建者。 + - 默认为空字符串。 + """ + + create_time = fields.DatetimeField(auto_now_add=True, description="创建时间", null=True) + """ + 创建时间。 + - 自动设置为当前时间。 + - 允许为空(null=True)。 + """ + + update_by = fields.CharField(max_length=255, default='', description="更新者") + """ + 更新者。 + - 默认为空字符串。 + """ + + update_time = fields.DatetimeField(auto_now=True, description="更新时间", null=True) + """ + 更新时间。 + - 自动更新为当前时间。 + - 允许为空(null=True)。 + """ + + class Meta: + abstract = True # 标记为抽象类,不会创建对应的数据库表 + ordering = ["-create_time"] # 默认按创建时间倒序排序 + indexes = ("del_flag",) # 为 del_flag 字段创建索引 diff --git a/models/department.py b/models/department.py new file mode 100644 index 0000000..63995be --- /dev/null +++ b/models/department.py @@ -0,0 +1,155 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 03:21 +# @UpdateTime : 2025/01/18 03:21 +# @Author : sonder +# @File : department.py +# @Software : PyCharm +# @Comment : 本程序 +from tortoise import fields + +from models.common import BaseModel + + +class Department(BaseModel): + """ + 部门表模型。 + """ + + name = fields.CharField( + max_length=100, + description="部门名称", + source_field="name" # 映射到数据库字段 name + ) + """ + 部门名称。 + - 最大长度为 100 个字符。 + - 映射到数据库字段 name。 + """ + + parent_id = fields.CharField( + max_length=36, + default="", + null=True, + description="父部门ID", + source_field="parent_id" # 映射到数据库字段 parent_id + ) + """ + 父部门ID。 + - 用于表示部门的层级关系。 + - 默认为空字符串,表示顶级部门。 + - 映射到数据库字段 parent_id。 + """ + + sort = fields.IntField( + default=0, + description="排序权重(0最高)", + source_field="sort" # 映射到数据库字段 sort + ) + """ + 排序权重。 + - 用于部门列表的排序,值越小越靠前。 + - 默认为 0。 + - 映射到数据库字段 sort。 + """ + + phone = fields.CharField( + max_length=30, + null=True, + description="部门电话", + source_field="phone" # 映射到数据库字段 phone + ) + """ + 部门电话。 + - 最大长度为 30 个字符。 + - 允许为空。 + - 映射到数据库字段 phone。 + """ + + principal = fields.CharField( + max_length=64, + description="部门负责人", + source_field="principal" # 映射到数据库字段 principal + ) + """ + 部门负责人。 + - 最大长度为 64 个字符。 + - 映射到数据库字段 principal。 + """ + + email = fields.CharField( + max_length=128, + null=True, + description="部门邮箱", + source_field="email" # 映射到数据库字段 email + ) + """ + 部门邮箱。 + - 最大长度为 128 个字符。 + - 允许为空。 + - 映射到数据库字段 email。 + """ + + status = fields.SmallIntField( + default=1, + description="状态(0正常 1停用)", + source_field="status" # 映射到数据库字段 status + ) + """ + 状态。 + - 1 表示正常,0 表示停用。 + - 默认为 1。 + - 映射到数据库字段 status。 + """ + + remark = fields.CharField( + max_length=255, + null=True, + description="备注信息", + source_field="remark" # 映射到数据库字段 remark + ) + """ + 备注信息。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 remark。 + """ + + class Meta: + table = "department" # 数据库表名 + table_description = "部门表" # 表描述 + ordering = ["sort", "-create_time"] # 默认按排序权重和创建时间排序 + + +class DepartmentRole(BaseModel): + """ + 部门角色表模型。 + """ + + department = fields.ForeignKeyField( + "models.Department", + related_name="department_roles", + description="部门ID", + source_field="department_id" # 映射到数据库字段 department_id + ) + """ + 部门ID。 + - 外键关联到 Department 表。 + - 映射到数据库字段 department_id。 + """ + + role = fields.ForeignKeyField( + "models.Role", + related_name="department_roles", + description="角色ID", + source_field="role_id" # 映射到数据库字段 role_id + ) + """ + 角色ID。 + - 外键关联到 Role 表。 + - 映射到数据库字段 role_id。 + """ + + class Meta: + table = "department_role" # 数据库表名 + table_description = "部门角色表" # 表描述 + unique_together = (("department_id", "role_id"),) # 唯一约束,防止重复分配 diff --git a/models/file.py b/models/file.py new file mode 100644 index 0000000..d84454e --- /dev/null +++ b/models/file.py @@ -0,0 +1,93 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/19 00:46 +# @UpdateTime : 2025/01/19 00:46 +# @Author : sonder +# @File : file.py +# @Software : PyCharm +# @Comment : 本程序 +from tortoise import fields + +from models.common import BaseModel + + +class File(BaseModel): + """ + 文件表模型。 + """ + + name = fields.CharField( + max_length=255, + description="文件名", + source_field="name" # 映射到数据库字段 name + ) + """ + 文件名。 + - 包括文件扩展名。 + - 最大长度为 255 个字符。 + - 映射到数据库字段 name。 + """ + + size = fields.BigIntField( + description="文件大小(字节)", + source_field="size" # 映射到数据库字段 size + ) + """ + 文件大小。 + - 单位:字节。 + - 映射到数据库字段 size。 + """ + + file_type = fields.CharField( + max_length=100, + description="文件类型", + source_field="file_type" # 映射到数据库字段 file_type + ) + """ + 文件类型。 + - 例如:image/png、application/pdf。 + - 最大长度为 100 个字符。 + - 映射到数据库字段 file_type。 + """ + + absolute_path = fields.CharField( + max_length=512, + description="绝对路径", + source_field="absolute_path" # 映射到数据库字段 absolute_path + ) + """ + 绝对路径。 + - 文件在服务器上的绝对路径。 + - 最大长度为 512 个字符。 + - 映射到数据库字段 absolute_path。 + """ + + relative_path = fields.CharField( + max_length=512, + description="相对路径", + source_field="relative_path" # 映射到数据库字段 relative_path + ) + """ + 相对路径。 + - 文件相对于某个根目录的相对路径。 + - 最大长度为 512 个字符。 + - 映射到数据库字段 relative_path。 + """ + + uploader = fields.ForeignKeyField( + "models.User", + related_name="uploaded_files", + null=True, # 允许为空 + description="上传人员", + source_field="uploader_id" # 映射到数据库字段 uploader_id + ) + """ + 上传人员。 + - 外键关联到 User 表。 + - 允许为空(例如系统自动上传的文件)。 + - 映射到数据库字段 uploader_id。 + """ + + class Meta: + table = "file" # 数据库表名 + table_description = "文件表" # 表描述 + ordering = ["-create_time"] # 默认按创建时间倒序排序 diff --git a/models/i18n.py b/models/i18n.py new file mode 100644 index 0000000..d317263 --- /dev/null +++ b/models/i18n.py @@ -0,0 +1,87 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/02/04 15:43 +# @UpdateTime : 2025/02/04 15:43 +# @Author : sonder +# @File : i18n.py +# @Software : PyCharm +# @Comment : 本程序 + +from tortoise import fields + +from models.common import BaseModel + + +class Locale(BaseModel): + """ + 语言模型. + """ + code = fields.CharField( + max_length=10, + description="语言代码", + source_field="code", + unique=True + ) + """ + 语言代码。 + - 例如:en(英语)、zh(中文)、fr(法语)。 + - 最大长度为10个字符。 + - 映射到数据库字段 code + """ + + name = fields.CharField( + max_length=50, + description="语言名称", + source_field="name", + unique=True + ) + """ + 语言名称。 + - 最大长度为50个字符。 + - 映射到数据库字段 name + """ + + class Meta: + table = "locale" + table_description = "语言表" + + +class I18n(BaseModel): + """ + 国际化模型. + """ + key = fields.CharField( + max_length=255, + description="国际化key", + source_field="key" + ) + """ + 国际化key。 + - 最大长度为255个字符。 + - 映射到数据库字段 key + """ + + locale = fields.ForeignKeyField( + "models.Locale", + related_name="i18n", + description="语言", + source_field="locale_id" + ) + """ + 语言。 + - 关联到 Locale 模型。 + - 映射到数据库字段 locale_id + """ + + translation = fields.TextField( + description="翻译内容", + source_field="translation" + ) + """ + 翻译内容。 + - 存储具体的翻译文本。 + - 映射到数据库字段 translation + """ + + class Meta: + table = "i18n" + table_description = "国际化表" diff --git a/models/log.py b/models/log.py new file mode 100644 index 0000000..294c1f8 --- /dev/null +++ b/models/log.py @@ -0,0 +1,345 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/19 00:03 +# @UpdateTime : 2025/01/19 00:03 +# @Author : sonder +# @File : log.py +# @Software : PyCharm +# @Comment : 本程序 +from tortoise import fields + +from models.common import BaseModel + + +class LoginLog(BaseModel): + """ + 系统访问记录表模型。 + """ + + user = fields.ForeignKeyField( + "models.User", + related_name="login_logs", + description="用户ID", + source_field="user_id" # 映射到数据库字段 user_id + ) + """ + 用户ID。 + - 外键关联到 User 表。 + - 映射到数据库字段 user_id。 + """ + + login_ip = fields.CharField( + max_length=50, + description="登录IP地址", + source_field="login_ip" # 映射到数据库字段 login_ip + ) + """ + 登录IP地址。 + - 最大长度为 50 个字符。 + - 映射到数据库字段 login_ip。 + """ + + login_location = fields.CharField( + max_length=255, + null=True, + description="登录地点", + source_field="login_location" # 映射到数据库字段 login_location + ) + """ + 登录地点。 + - 根据 IP 地址解析的地理位置信息。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 login_location。 + """ + + browser = fields.CharField( + max_length=255, + null=True, + description="浏览器类型", + source_field="browser" # 映射到数据库字段 browser + ) + """ + 浏览器类型。 + - 记录用户登录时使用的浏览器类型。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 browser。 + """ + + os = fields.CharField( + max_length=255, + null=True, + description="操作系统", + source_field="os" # 映射到数据库字段 os + ) + """ + 操作系统。 + - 记录用户登录时使用的操作系统。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 os。 + """ + + status = fields.SmallIntField( + default=1, + description="登录状态(1成功,0失败)", + source_field="status" # 映射到数据库字段 status + ) + """ + 登录状态。 + - 1:成功 + - 0:失败 + - 默认为 1。 + - 映射到数据库字段 status。 + """ + + login_time = fields.DatetimeField( + auto_now_add=True, + description="登录时间", + source_field="login_time" # 映射到数据库字段 login_time + ) + """ + 登录时间。 + - 自动设置为当前时间。 + - 映射到数据库字段 login_time。 + """ + + session_id = fields.CharField( + max_length=36, + null=True, + description="会话ID", + source_field="session_id" + ) + """ + 会话ID。 + - 记录用户登录时的会话ID。 + - 允许为空。 + - 映射到数据库字段 session_id。 + """ + + class Meta: + table = "login_log" # 数据库表名 + table_description = "系统访问记录表" # 表描述 + ordering = ["-login_time"] # 默认按登录时间倒序排序 + + +class OperationLog(BaseModel): + """ + 操作日志表模型。 + """ + + operation_name = fields.CharField( + max_length=255, + description="操作名称", + source_field="operation_name" # 映射到数据库字段 operation_name + ) + """ + 操作名称。 + - 最大长度为 255 个字符。 + - 映射到数据库字段 operation_name。 + """ + + operation_type = fields.SmallIntField( + description="操作类型(增删改查)", + source_field="operation_type" # 映射到数据库字段 operation_type + ) + """ + 操作类型。 + - 增、删、改、查等操作类型。 + - 最大长度为 50 个字符。 + - 映射到数据库字段 operation_type。 + """ + + request_path = fields.TextField( + description="请求路径", + source_field="request_path" # 映射到数据库字段 request_path + ) + """ + 请求路径。 + - 记录用户请求的 API 路径。 + - 最大长度为 255 个字符。 + - 映射到数据库字段 request_path。 + """ + + request_method = fields.CharField( + max_length=10, + description="请求方法", + source_field="request_method" # 映射到数据库字段 request_method + ) + """ + 请求方法。 + - 记录用户请求的 HTTP 方法(如 GET、POST、PUT、DELETE)。 + - 最大长度为 10 个字符。 + - 映射到数据库字段 request_method。 + """ + + operator = fields.ForeignKeyField( + "models.User", + related_name="operation_logs", + null=True, # 允许操作人员为空 + description="操作人员", + source_field="operator_id" # 映射到数据库字段 operator_id + ) + """ + 操作人员。 + - 外键关联到 User 表。 + - 允许为空。 + - 映射到数据库字段 operator_id。 + """ + + department = fields.ForeignKeyField( + "models.Department", + related_name="operation_logs", + null=True, # 允许操作人员为空 + description="操作人员所属部门", + source_field="department_id" # 映射到数据库字段 department_id + ) + """ + 操作人员所属部门。 + - 外键关联到 Department 表。 + - 允许为空。 + - 映射到数据库字段 department_id。 + """ + + department_name = fields.CharField( + max_length=255, + description="部门名称", + source_field="department_name" # 映射到数据库字段 department_name + ) + """ + 部门名称。 + - 记录操作人员所属的部门名称。 + - 最大长度为 255 个字符。 + - 映射到数据库字段 department_name。 + """ + + host = fields.CharField( + max_length=50, + description="主机地址", + source_field="host" # 映射到数据库字段 host + ) + """ + 主机地址。 + - 记录用户请求的 IP 地址。 + - 最大长度为 50 个字符。 + - 映射到数据库字段 host。 + """ + + location = fields.CharField( + max_length=255, + null=True, + description="操作地点", + source_field="location" # 映射到数据库字段 location + ) + """ + 操作地点。 + - 根据 IP 地址解析的地理位置信息。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 location。 + """ + + user_agent = fields.TextField( + null=True, + description="用户请求头", + source_field="user_agent" # 映射到数据库字段 user_agent + ) + """ + 用户请求头。 + - 记录用户请求的 User-Agent 信息。 + - 允许为空。 + - 映射到数据库字段 user_agent。 + """ + + browser = fields.CharField( + max_length=255, + null=True, + description="浏览器类型", + source_field="browser" # 映射到数据库字段 browser + ) + """ + 浏览器类型。 + - 记录用户登录时使用的浏览器类型。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 browser。 + """ + + os = fields.CharField( + max_length=255, + null=True, + description="操作系统", + source_field="os" # 映射到数据库字段 os + ) + """ + 操作系统。 + - 记录用户登录时使用的操作系统。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 os。 + """ + + request_params = fields.TextField( + null=True, + description="请求参数", + source_field="request_params" # 映射到数据库字段 request_params + ) + """ + 请求参数。 + - 记录用户请求的参数(任意格式,如字符串、JSON、XML 等)。 + - 允许为空。 + - 映射到数据库字段 request_params。 + """ + + response_result = fields.TextField( + null=True, + description="返回结果", + source_field="response_result" # 映射到数据库字段 response_result + ) + """ + 返回结果。 + - 记录操作的返回结果(任意格式,如字符串、JSON、XML 等)。 + - 允许为空。 + - 映射到数据库字段 response_result。 + """ + + status = fields.SmallIntField( + default=1, + description="操作状态(1成功,0失败)", + source_field="status" # 映射到数据库字段 status + ) + """ + 操作状态。 + - 1:成功 + - 0:失败 + - 默认为 1。 + - 映射到数据库字段 status。 + """ + + operation_time = fields.DatetimeField( + auto_now_add=True, + description="操作时间", + source_field="operation_time" # 映射到数据库字段 operation_time + ) + """ + 操作时间。 + - 自动设置为当前时间。 + - 映射到数据库字段 operation_time。 + """ + + cost_time = fields.FloatField( + default=0, + description="消耗时间(毫秒)", + source_field="cost_time" # 映射到数据库字段 cost_time + ) + """ + 消耗时间。 + - 记录操作消耗的时间(单位:毫秒)。 + - 默认为 0。 + - 映射到数据库字段 cost_time。 + """ + + class Meta: + table = "operation_log" # 数据库表名 + table_description = "操作日志表" # 表描述 + ordering = ["-operation_time"] # 默认按操作时间倒序排序 diff --git a/models/permission.py b/models/permission.py new file mode 100644 index 0000000..2ea5342 --- /dev/null +++ b/models/permission.py @@ -0,0 +1,286 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 03:40 +# @UpdateTime : 2025/01/18 03:40 +# @Author : sonder +# @File : permission.py +# @Software : PyCharm +# @Comment : 本程序 +from tortoise import fields + +from models.common import BaseModel + + +class Permission(BaseModel): + """ + 权限表模型。 + """ + + menu_type = fields.SmallIntField( + description="菜单类型(0菜单、1iframe、2外链、3按钮)", + source_field="menu_type" # 映射到数据库字段 menu_type + ) + """ + 菜单类型。 + - 0:菜单 + - 1:iframe + - 2:外链 + - 3:按钮 + - 映射到数据库字段 menu_type。 + """ + + parent_id = fields.CharField( + max_length=36, + default="", + description="父权限ID", + source_field="parent_id" # 映射到数据库字段 parent_id + ) + """ + 父权限ID。 + - 用于表示权限的层级关系。 + - 默认为空字符串,表示顶级权限。 + - 映射到数据库字段 parent_id。 + """ + + title = fields.CharField( + max_length=255, + description="菜单名称", + source_field="title" # 映射到数据库字段 title + ) + """ + 菜单名称。 + - 兼容国际化、非国际化。 + - 最大长度为 255 个字符。 + - 映射到数据库字段 title。 + """ + + name = fields.CharField( + max_length=255, + null=True, + description="路由名称", + source_field="name" # 映射到数据库字段 name + ) + """ + 路由名称。 + - 必须唯一,并且与前端路由组件的 `name` 保持一致。 + - 最大长度为 255 个字符。 + - 映射到数据库字段 name。 + """ + + path = fields.CharField( + max_length=255, + description="路由路径", + source_field="path" # 映射到数据库字段 path + ) + """ + 路由路径。 + - 最大长度为 255 个字符。 + - 映射到数据库字段 path。 + """ + + component = fields.CharField( + max_length=255, + null=True, + description="组件路径", + source_field="component" # 映射到数据库字段 component + ) + """ + 组件路径。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 component。 + """ + + rank = fields.IntField( + default=1, + description="菜单排序", + source_field="rank" # 映射到数据库字段 rank + ) + """ + 菜单排序。 + - 平台规定只有 `home` 路由的 `rank` 才能为 0。 + - 默认为 1。 + - 映射到数据库字段 rank。 + """ + + redirect = fields.CharField( + max_length=255, + null=True, + description="路由重定向", + source_field="redirect" # 映射到数据库字段 redirect + ) + """ + 路由重定向。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 redirect。 + """ + + icon = fields.CharField( + max_length=255, + null=True, + description="菜单图标", + source_field="icon" # 映射到数据库字段 icon + ) + """ + 菜单图标。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 icon。 + """ + + extra_icon = fields.CharField( + max_length=255, + null=True, + description="右侧图标", + source_field="extra_icon" # 映射到数据库字段 extra_icon + ) + """ + 右侧图标。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 extra_icon。 + """ + + enter_transition = fields.CharField( + max_length=255, + null=True, + description="进场动画", + source_field="enter_transition" # 映射到数据库字段 enter_transition + ) + """ + 进场动画。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 enter_transition。 + """ + + leave_transition = fields.CharField( + max_length=255, + null=True, + description="离场动画", + source_field="leave_transition" # 映射到数据库字段 leave_transition + ) + """ + 离场动画。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 leave_transition。 + """ + + active_path = fields.CharField( + max_length=255, + null=True, + description="菜单激活路径", + source_field="active_path" # 映射到数据库字段 active_path + ) + """ + 菜单激活路径。 + - 用于指定激活菜单的路径。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 active_path。 + """ + + auths = fields.CharField( + max_length=255, + null=True, + description="权限标识", + source_field="auths" # 映射到数据库字段 auths + ) + """ + 权限标识。 + - 用于按钮级别权限设置。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 auths。 + """ + + frame_src = fields.CharField( + max_length=255, + null=True, + description="iframe链接地址", + source_field="frame_src" # 映射到数据库字段 frame_src + ) + """ + iframe 链接地址。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 frame_src。 + """ + + frame_loading = fields.BooleanField( + default=True, + description="iframe加载动画", + source_field="frame_loading" # 映射到数据库字段 frame_loading + ) + """ + iframe 加载动画。 + - 是否开启首次加载动画。 + - 默认为 True。 + - 映射到数据库字段 frame_loading。 + """ + + keep_alive = fields.BooleanField( + default=False, + description="缓存页面", + source_field="keep_alive" # 映射到数据库字段 keep_alive + ) + """ + 缓存页面。 + - 是否缓存该路由页面。 + - 默认为 False。 + - 映射到数据库字段 keep_alive。 + """ + + hidden_tag = fields.BooleanField( + default=False, + description="隐藏标签页", + source_field="hidden_tag" # 映射到数据库字段 hidden_tag + ) + """ + 隐藏标签页。 + - 是否禁止将当前菜单名称添加到标签页。 + - 默认为 False。 + - 映射到数据库字段 hidden_tag。 + """ + + fixed_tag = fields.BooleanField( + default=False, + description="固定标签页", + source_field="fixed_tag" # 映射到数据库字段 fixed_tag + ) + """ + 固定标签页。 + - 是否固定显示在标签页且不可关闭。 + - 默认为 False。 + - 映射到数据库字段 fixed_tag。 + """ + + show_link = fields.BooleanField( + default=True, + description="显示菜单", + source_field="show_link" # 映射到数据库字段 show_link + ) + """ + 显示菜单。 + - 是否显示该菜单。 + - 默认为 True。 + - 映射到数据库字段 show_link。 + """ + + show_parent = fields.BooleanField( + default=True, + description="显示父级菜单", + source_field="show_parent" # 映射到数据库字段 show_parent + ) + """ + 显示父级菜单。 + - 是否显示父级菜单。 + - 默认为 True。 + - 映射到数据库字段 show_parent。 + """ + + class Meta: + table = "permission" # 数据库表名 + table_description = "权限表" # 表描述 + ordering = ["rank", "-create_time"] # 默认按排序权重和创建时间排序 diff --git a/models/role.py b/models/role.py new file mode 100644 index 0000000..e7179f5 --- /dev/null +++ b/models/role.py @@ -0,0 +1,119 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 23:00 +# @UpdateTime : 2025/01/18 23:00 +# @Author : sonder +# @File : role.py +# @Software : PyCharm +# @Comment : 本程序 +from tortoise import fields + +from models.common import BaseModel + + +class Role(BaseModel): + """ + 角色表模型。 + """ + + name = fields.CharField( + max_length=255, + description="角色名称", + source_field="role_name" # 映射到数据库字段 role_name + ) + """ + 角色名称。 + - 允许重复,因为不同部门可能有相同的角色名称。 + - 最大长度为 255 个字符。 + - 映射到数据库字段 role_name。 + """ + + code = fields.CharField( + max_length=255, + unique=True, + description="角色编码", + source_field="role_code" # 映射到数据库字段 role_code + ) + """ + 角色编码。 + - 用于系统内部识别角色。 + - 必须唯一。 + - 最大长度为 255 个字符。 + - 映射到数据库字段 role_code。 + """ + + description = fields.CharField( + max_length=255, + null=True, + description="角色描述", + source_field="role_description" # 映射到数据库字段 role_description + ) + """ + 角色描述。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 role_description。 + """ + + status=fields.SmallIntField( + default=1, + description="角色状态", + source_field="status" + ) + """ + 角色状态。 + - 1: 正常 + - 0: 禁用 + - 映射到数据库字段 status。 + """ + + permissions = fields.ManyToManyField( + "models.Permission", + related_name="roles", + through="role_permission", + description="角色权限" + ) + """ + 角色权限。 + - 多对多关系,表示角色拥有的权限。 + - 通过中间表 `role_permission` 关联权限表。 + """ + + department = fields.ForeignKeyField( + "models.Department", + related_name="roles", + null=True, + description="所属部门", + source_field="department_id" # 映射到数据库字段 department_id + ) + """ + 所属部门。 + - 表示角色所属的部门。 + - 如果为 null,则表示角色是全局角色。 + - 映射到数据库字段 department_id。 + """ + + class Meta: + table = "role" # 数据库表名 + table_description = "角色表" # 表描述 + ordering = ["-create_time"] # 默认按创建时间倒序排序 + + +class RolePermission(BaseModel): + """ + 角色权限中间表。 + """ + + role = fields.ForeignKeyField( + "models.Role", + related_name="role_permissions", + source_field="role_id" # 映射到数据库字段 role_id + ) + permission = fields.ForeignKeyField( + "models.Permission", + related_name="role_permissions", + source_field="permission_id" # 映射到数据库字段 permission_id + ) + + class Meta: + table = "role_permission" # 数据库表名 + table_description = "角色权限中间表" # 表描述 diff --git a/models/user.py b/models/user.py new file mode 100644 index 0000000..4f9e829 --- /dev/null +++ b/models/user.py @@ -0,0 +1,172 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 03:20 +# @UpdateTime : 2025/01/18 03:20 +# @Author : sonder +# @File : user.py +# @Software : PyCharm +# @Comment : 本程序 +from tortoise import fields + +from models.common import BaseModel + + +class User(BaseModel): + """ + 用户表模型。 + """ + + username = fields.CharField( + max_length=255, + unique=True, + description="用户名", + source_field="username" # 映射到数据库字段 username + ) + """ + 用户名。 + - 必须唯一。 + - 最大长度为 255 个字符。 + - 映射到数据库字段 username。 + """ + + password = fields.CharField( + max_length=255, + description="密码", + source_field="password" # 映射到数据库字段 password + ) + """ + 密码。 + - 存储加密后的密码。 + - 最大长度为 255 个字符。 + - 映射到数据库字段 password。 + """ + + email = fields.CharField( + max_length=255, + null=True, + description="邮箱", + source_field="email" # 映射到数据库字段 email + ) + """ + 邮箱。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 email。 + """ + + phone = fields.CharField( + max_length=30, + null=True, + description="手机号", + source_field="phone" # 映射到数据库字段 phone + ) + """ + 手机号。 + - 最大长度为 30 个字符。 + - 允许为空。 + - 映射到数据库字段 phone。 + """ + + nickname = fields.CharField( + max_length=255, + null=True, + description="昵称", + source_field="nickname" # 映射到数据库字段 nickname + ) + """ + 昵称。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 nickname。 + """ + + avatar = fields.CharField( + max_length=255, + null=True, + description="头像", + source_field="avatar" # 映射到数据库字段 avatar + ) + """ + 头像。 + - 最大长度为 255 个字符。 + - 允许为空。 + - 映射到数据库字段 avatar。 + """ + + gender = fields.SmallIntField( + default=0, + description="性别(1男,0女)", + source_field="gender" # 映射到数据库字段 gender + ) + """ + 性别。 + - 1:男 + - 0:女 + - 默认为 0。 + - 映射到数据库字段 gender。 + """ + + status = fields.SmallIntField( + default=1, + description="用户状态(1启用,0禁用)", + source_field="status" # 映射到数据库字段 status + ) + """ + 用户状态。 + - 1:启用 + - 0:禁用 + - 默认为 1。 + - 映射到数据库字段 status。 + """ + + department = fields.ForeignKeyField( + "models.Department", + related_name="users", + null=True, + description="所属部门", + source_field="department_id" # 映射到数据库字段 department_id + ) + """ + 所属部门。 + - 外键关联到 Department 表。 + - 如果为 null,则表示用户未分配部门。 + - 映射到数据库字段 department_id。 + """ + + roles = fields.ManyToManyField( + "models.Role", + related_name="users", + through="user_role", + description="用户角色" + ) + """ + 用户角色。 + - 多对多关系,表示用户拥有的角色。 + - 通过中间表 `user_role` 关联角色表。 + """ + + class Meta: + table = "user" # 数据库表名 + table_description = "用户表" # 表描述 + ordering = ["-create_time"] # 默认按创建时间倒序排序 + + +class UserRole(BaseModel): + """ + 用户角色中间表。 + """ + + user = fields.ForeignKeyField( + "models.User", + related_name="user_roles", + source_field="user_id" # 映射到数据库字段 user_id + ) + role = fields.ForeignKeyField( + "models.Role", + related_name="user_roles", + source_field="role_id" # 映射到数据库字段 role_id + ) + + class Meta: + table = "user_role" # 数据库表名 + table_description = "用户角色中间表" # 表描述 + unique_together = (("user_id", "role_id"),) # 唯一约束,防止重复分配 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4a9eeb7 Binary files /dev/null and b/requirements.txt differ diff --git a/schemas/__init__.py b/schemas/__init__.py new file mode 100644 index 0000000..9ae1216 --- /dev/null +++ b/schemas/__init__.py @@ -0,0 +1,7 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 02:43 +# @UpdateTime : 2025/01/18 02:43 +# @Author : sonder +# @File : __init__.py.py +# @Software : PyCharm +# @Comment : 本程序 diff --git a/schemas/cache.py b/schemas/cache.py new file mode 100644 index 0000000..d941777 --- /dev/null +++ b/schemas/cache.py @@ -0,0 +1,57 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/02/04 15:20 +# @UpdateTime : 2025/02/04 15:20 +# @Author : sonder +# @File : cache.py +# @Software : PyCharm +# @Comment : 本程序 +from typing import Optional, Any, List + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from schemas.common import BaseResponse + + +class CacheMonitor(BaseModel): + """ + 缓存监控信息 + """ + model_config = ConfigDict(alias_generator=to_camel) + + command_stats: Optional[List] = Field(default=[], description='命令统计') + db_size: Optional[int] = Field(default=None, description='Key数量') + info: Optional[dict] = Field(default={}, description='Redis信息') + + +class CacheInfo(BaseModel): + """ + 缓存信息 + """ + model_config = ConfigDict(alias_generator=to_camel) + + cache_key: Optional[str] = Field(default=None, description='缓存键名') + cache_name: Optional[str] = Field(default=None, description='缓存名称') + cache_value: Optional[Any] = Field(default=None, description='缓存内容') + remark: Optional[str] = Field(default=None, description='备注') + + +class GetCacheMonitorResponse(BaseResponse): + """ + 获取缓存监控信息响应 + """ + data: CacheMonitor = Field(default={}, description="缓存监控信息查询结果") + + +class GetCacheInfoResponse(BaseResponse): + """ + 获取缓存信息响应 + """ + data: List[CacheInfo] = Field(default=[], description="缓存信息查询结果") + + +class GetCacheKeysListResponse(BaseResponse): + """ + 获取缓存键名列表 + """ + data: List[str] = Field(default=[], description="缓存键名列表") diff --git a/schemas/common.py b/schemas/common.py new file mode 100644 index 0000000..d5a161a --- /dev/null +++ b/schemas/common.py @@ -0,0 +1,48 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/19 01:44 +# @UpdateTime : 2025/01/19 01:44 +# @Author : sonder +# @File : common.py +# @Software : PyCharm +# @Comment : 本程序 +from typing import List + +from pydantic import BaseModel, Field + + +class BaseResponse(BaseModel): + """ + 基础响应模型 + """ + code: int = Field(default=200, description="响应码") + msg: str = Field(default="操作成功!", description="响应信息") + data: dict = Field(default=None, description="响应数据") + success: bool = Field(default=True, description="操作是否成功") + time: str = Field(default="", description="响应时间") + + +class ListQueryResult(BaseModel): + """ + 列表查询结果 + """ + result: List = Field(default=[], description="列表数据") + total: int = Field(default=0, description="总条数") + page: int = Field(default=1, description="当前页码") + + +class DeleteListParams(BaseModel): + """ + 批量删除参数 + """ + ids: List[str] = Field(default=[], description="删除ID列表") + + class Config: + json_schema_extra = { + "example": { + "ids": [ + "1", + "2", + "3" + ] + } + } diff --git a/schemas/department.py b/schemas/department.py new file mode 100644 index 0000000..5b21d49 --- /dev/null +++ b/schemas/department.py @@ -0,0 +1,178 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/19 02:19 +# @UpdateTime : 2025/01/19 02:19 +# @Author : sonder +# @File : department.py +# @Software : PyCharm +# @Comment : 本程序 +from datetime import datetime +from typing import Optional, List + +from pydantic import BaseModel, Field + +from schemas.common import BaseResponse, ListQueryResult + + +class DepartmentInfo(BaseModel): + """ + 部门表基础模型。 + """ + id: str = Field(..., description="主键") + create_by: str = Field(default="", description="创建者") + create_time: Optional[datetime] = Field(default=None, description="创建时间") + update_by: str = Field(default="", description="更新者") + update_time: Optional[datetime] = Field(default=None, description="更新时间") + name: str = Field(..., max_length=100, description="部门名称") + parent_id: Optional[str] = Field(default=None, max_length=36, description="父部门ID") + sort: int = Field(default=0, description="排序权重(0最高)") + phone: Optional[str] = Field(default=None, max_length=30, description="部门电话") + principal: str = Field(..., max_length=64, description="部门负责人") + email: Optional[str] = Field(default=None, max_length=128, description="部门邮箱") + remark: Optional[str] = Field(default=None, max_length=255, description="备注信息") + status: Optional[int] = Field(default=None, description="状态") + + class Config: + json_schema_extra = { + "example": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "create_by": "admin", + "create_time": "2023-10-01T12:00:00", + "update_by": "admin", + "update_time": "2023-10-01T12:00:00", + "name": "研发部", + "parent_id": "", + "sort": 0, + "phone": "1234567890", + "principal": "张三", + "email": "dev@example.com", + "remark": "研发部门", + "status": 1 + } + } + + +class GetDepartmentInfoResponse(BaseResponse): + """ + 获取部门信息响应模型。 + """ + data: DepartmentInfo = Field(default=None, description="响应数据") + + +class AddDepartmentParams(BaseModel): + """ + 添加部门参数模型。 + """ + name: str = Field(..., max_length=100, description="部门名称") + parent_id: Optional[str] = Field(default=None, max_length=36, description="父部门ID") + sort: int = Field(default=0, description="排序权重(0最高)") + phone: Optional[str] = Field(default=None, max_length=30, description="部门电话") + principal: str = Field(..., max_length=64, description="部门负责人") + email: Optional[str] = Field(default=None, max_length=128, description="部门邮箱") + remark: Optional[str] = Field(default=None, max_length=255, description="备注信息") + status: Optional[int] = Field(default=None, description="状态") + + class Config: + json_schema_extra = { + "example": { + "name": "研发部", + "parent_id": "", + "sort": 0, + "phone": "1234567890", + "principal": "张三", + "email": "dev@example.com", + "remark": "研发部门", + "status": 1 + } + } + + +class DeleteDepartmentListParams(BaseModel): + """ + 删除部门参数模型。 + """ + ids: List[str] = Field(..., description="部门ID列表") + + +class GetDepartmentListResult(ListQueryResult): + """ + 获取部门列表结果模型。 + """ + result: List[DepartmentInfo] = Field(default=[], description="部门列表") + + +class GetDepartmentListResponse(BaseResponse): + """ + 获取部门列表响应模型。 + """ + data: GetDepartmentListResult = Field(default=None, description="响应数据") + + +class AddDepartmentRoleParams(BaseModel): + """ + 添加部门角色参数模型。 + """ + department_id: str = Field(..., max_length=36, description="部门ID") + role_id: str = Field(..., max_length=36, description="角色ID") + + class Config: + json_schema_extra = { + "example": { + "department_id": "550e8400-e29b-41d4-a716-446655440000", + "role_id": "550e8400-e29b-41d4-a716-446655440000" + } + } + + +class DepartmentRoleInfo(BaseModel): + """ + 部门角色信息模型。 + """ + id: str = Field(..., max_length=36, description="主键ID") + department_id: str = Field(..., max_length=36, description="部门ID") + department_name: str = Field(..., max_length=100, description="部门名称") + department_phone: str = Field(..., max_length=30, description="部门电话") + department_principal: str = Field(..., max_length=64, description="部门负责人") + department_email: str = Field(..., max_length=128, description="部门邮箱") + role_name: str = Field(..., max_length=100, description="角色名称") + role_code: str = Field(..., max_length=100, description="角色编码") + role_id: str = Field(..., max_length=36, description="角色ID") + create_time: datetime = Field(..., description="创建时间") + update_time: datetime = Field(..., description="更新时间") + + class Config: + json_schema_extra = { + "example": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "department_id": "550e8400-e29b-41d4-a716-446655440000", + "department_name": "研发部", + "department_phone": "1234567890", + "department_principal": "张三", + "department_email": "dev@example.com", + "role_name": "管理员", + "role_code": "admin", + "role_id": "550e8400-e29b-41d4-a716-446655440000", + "create_time": "2023-10-01T12:00:00", + "update_time": "2023-10-01T12:00:00" + } + } + + +class GetDepartmentRoleInfoResponse(BaseResponse): + """ + 获取部门角色信息响应模型。 + """ + data: DepartmentRoleInfo = Field(default=None, description="响应数据") + + +class GetDepartmentRoleListResult(ListQueryResult): + """ + 获取部门角色列表结果模型。 + """ + result: List[DepartmentRoleInfo] = Field(default=[], description="部门角色列表") + + +class GetDepartmentRoleListResponse(BaseResponse): + """ + 获取部门角色列表响应模型。 + """ + data: GetDepartmentRoleListResult = Field(default=None, description="响应数据") diff --git a/schemas/file.py b/schemas/file.py new file mode 100644 index 0000000..91db9a7 --- /dev/null +++ b/schemas/file.py @@ -0,0 +1,79 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/26 01:15 +# @UpdateTime : 2025/01/26 01:15 +# @Author : sonder +# @File : file.py +# @Software : PyCharm +# @Comment : 本程序 + +from typing import Optional, List + +from pydantic import BaseModel, Field + +from schemas.common import BaseResponse, ListQueryResult + + +class FileInfo(BaseModel): + """ + 文件信息 + """ + id: str = Field(..., title="文件id") + name: str = Field(..., title="文件名") + size: int = Field(..., title="文件大小") + file_type: str = Field(..., title="文件类型") + absolute_path: str = Field(..., title="绝对路径") + relative_path: str = Field(..., title="相对路径") + uploader_id: Optional[str] = Field(..., title="上传者ID") + uploader_username: Optional[str] = Field(..., title="上传者用户名") + uploader_nickname: Optional[str] = Field(..., title="上传者昵称") + uploader_department_id: Optional[str] = Field(..., title="上传者部门ID") + uploader_department_name: Optional[str] = Field(..., title="上传者部门名称") + update_time: str = Field(..., title="更新时间") + create_time: str = Field(..., title="创建时间") + + class Config: + json_schema_extra = { + "example": { + "id": "1", + "name": "test.txt", + "size": 1024, + "file_type": "text/plain", + "absolute_path": "/home/test.txt", + "relative_path": "/test.txt", + "uploader_id": "1", + "uploader_username": "test", + "uploader_nickname": "test", + "uploader_department_id": "1", + "uploader_department_name": "test", + "update_time": "2025-01-26 01:15:00", + "create_time": "2025-01-26 01:15:00" + } + } + + +class UploadFileResponse(BaseResponse): + """ + 上传文件响应模型 + """ + data: FileInfo = Field(..., title="文件信息") + + +class GetFileInfoResponse(BaseResponse): + """ + 获取文件响应模型 + """ + data: FileInfo = Field(..., title="文件信息") + + +class GetFileListResult(ListQueryResult): + """ + 获取文件列表结果模型 + """ + result: List[FileInfo] = Field(..., title="文件列表") + + +class GetFileListResponse(BaseResponse): + """ + 获取文件列表响应模型 + """ + data: GetFileListResult = Field(..., title="文件列表结果") diff --git a/schemas/i18n.py b/schemas/i18n.py new file mode 100644 index 0000000..7bc53a1 --- /dev/null +++ b/schemas/i18n.py @@ -0,0 +1,121 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/02/04 15:55 +# @UpdateTime : 2025/02/04 15:55 +# @Author : sonder +# @File : i18n.py +# @Software : PyCharm +# @Comment : 本程序 +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field, ConfigDict +from pydantic.alias_generators import to_camel + +from schemas.common import BaseResponse, ListQueryResult + + +class LocaleInfo(BaseModel): + """ + 语言模型信息 + """ + model_config = ConfigDict(alias_generator=to_camel) + id: str = Field(..., description="主键") + create_by: str = Field(default="", description="创建者") + create_time: Optional[datetime] = Field(default=None, description="创建时间") + update_by: str = Field(default="", description="更新者") + update_time: Optional[datetime] = Field(default=None, description="更新时间") + code: str = Field(default="", description="语言代码") + name: str = Field(default="", description="语言名称") + + +class AddLocaleParams(BaseModel): + """ + 添加语言类型参数 + """ + code: str = Field(default="", description="语言代码") + name: str = Field(default="", description="语言名称") + + +class GetLocaleInfoResponse(BaseResponse): + """ + 获取语言模型信息响应 + """ + data: LocaleInfo = Field(default=None, description="语言模型信息") + + +class GetLocaleInfoResult(ListQueryResult): + """ + 获取语言模型信息结果 + """ + result: List[LocaleInfo] = Field(default=[], description="语言模型信息") + + +class GetLocaleListResponse(BaseResponse): + """ + 获取语言模型信息响应 + """ + data: GetLocaleInfoResult = Field(default={}, description="语言模型信息") + + +class I18nInfo(BaseModel): + """ + 国际化模型信息 + """ + model_config = ConfigDict(alias_generator=to_camel) + id: str = Field(..., description="主键") + create_by: str = Field(default="", description="创建者") + create_time: Optional[datetime] = Field(default=None, description="创建时间") + update_by: str = Field(default="", description="更新者") + update_time: Optional[datetime] = Field(default=None, description="更新时间") + key: str = Field(default="", description="国际化key") + locale_code: str = Field(default="", description="语言代码") + locale_id: str = Field(default="", description="语言ID") + locale_name: str = Field(default="", description="语言名称") + translation: str = Field(default="", description="翻译内容") + + +class AddI18nParams(BaseModel): + """ + 添加国际化参数 + """ + key: str = Field(default="", description="国际化key") + locale_id: str = Field(default="", description="语言ID") + translation: str = Field(default="", description="翻译内容") + + +class GetI18nInfoResponse(BaseResponse): + """ + 获取国际化模型信息响应 + """ + data: I18nInfo = Field(default=None, description="国际化模型信息") + + +class GetI18nInfoResult(ListQueryResult): + """ + 获取国际化模型信息结果 + """ + result: List[I18nInfo] = Field(default=[], description="国际化模型信息") + + +class GetI18nListResponse(BaseResponse): + """ + 获取国际化模型信息响应 + """ + data: GetI18nInfoResult = Field(default=None, description="国际化模型信息") + + +class I18nList(BaseModel): + """ + 国际化模型信息 + """ + model_config = ConfigDict(alias_generator=to_camel) + locale: str = Field(default="", description="语言名称") + name: str = Field(default="", description="语言名称") + data: dict = Field(default={}, description="国际化模型信息") + + +class GetI18nInfoListResponse(BaseResponse): + """ + 获取国际化模型信息响应 + """ + data: I18nList = Field(default=None, description="国际化模型信息") diff --git a/schemas/log.py b/schemas/log.py new file mode 100644 index 0000000..1aa0b23 --- /dev/null +++ b/schemas/log.py @@ -0,0 +1,135 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/27 21:41 +# @UpdateTime : 2025/01/27 21:41 +# @Author : sonder +# @File : log.py +# @Software : PyCharm +# @Comment : 本程序 + +from pydantic import BaseModel, Field + +from schemas.common import BaseResponse, ListQueryResult + + +class LoginLogInfo(BaseModel): + """ + 登录日志信息 + """ + id: str = Field(default="", description="登录日志ID") + user_id: str = Field(default="", description="用户ID") + username: str = Field(default="", description="用户名") + user_nickname: str = Field(default="", description="用户昵称") + department_id: str = Field(default="", description="部门ID") + department_name: str = Field(default="", description="部门名称") + login_ip: str = Field(default="", description="登录IP") + login_location: str = Field(default="", description="登录地点") + browser: str = Field(default="", description="登录浏览器") + os: str = Field(default="", description="登录操作系统") + status: int = Field(default="", description="登录状态") + login_time: str = Field(default="", description="登录时间") + session_id: str = Field(default="", description="会话ID") + online: bool = Field(default=False, description="是否在线") + create_time: str = Field(default="", description="创建时间") + update_time: str = Field(default="", description="更新时间") + + class Config: + json_schema_extra = { + "example": { + "id": "1", + "user_id": "1", + "username": "admin", + "user_nickname": "管理员", + "department_id": "1", + "department_name": "超级管理员", + "login_ip": "127.0.0.1", + "login_location": "中国", + "browser": "Chrome", + "os": "Windows", + "status": 1, + "login_time": "2025-01-27 21:41:00", + "session_id": "1", + "online": True, + "create_time": "2025-01-27 21:41:00", + "update_time": "2025-01-27 21:41:00" + } + } + + +class LoginLogResult(ListQueryResult): + """ + 登录日志查询结果 + """ + result: list[LoginLogInfo] = Field(default=[], description="登录日志列表") + + +class GetLoginLogResponse(BaseResponse): + """ + 获取登录日志响应 + """ + data: LoginLogResult = Field(default=[], description="登录日志查询结果") + + +class OperationLogInfo(BaseModel): + """ + 操作日志信息 + """ + id: str = Field(default="", description="操作日志ID") + operation_name: str = Field(default="", description="操作名称") + operation_type: int = Field(default=1, description="操作类型") + request_path: str = Field(default="", description="请求路径") + request_method: str = Field(default="", description="请求方法") + request_params: str = Field(default="", description="请求参数") + request_result: str = Field(default="", description="请求结果") + host: str = Field(default="", description="请求主机") + location: str = Field(default="", description="请求地址") + browser: str = Field(default="", description="请求浏览器") + os: str = Field(default="", description="请求操作系统") + user_agent: str = Field(default="", description="请求头") + operator_id: str = Field(default="", description="操作人员ID") + operator_name: str = Field(default="", description="操作人员名称") + operator_nickname: str = Field(default="", description="操作人员昵称") + department_id: str = Field(default="", description="操作人员部门ID") + department_name: str = Field(default="", description="操作人员部门名称") + operation_time: str = Field(default="", description="操作时间") + cost_time: float = Field(default=0.0, description="操作耗时") + status: int = Field(default="", description="操作状态") + + class Config: + json_schema_extra = { + "example": { + "id": "1", + "operation_name": "登录", + "operation_type": 1, + "request_path": "/login", + "request_method": "POST", + "request_params": "{\"username\": \"admin\", \"password\": \"123456\"}", + "request_result": "{\"code\": 200, \"data\": {\"token\":\"eyJ0eXAiOiJKV1Qi}", + "host": "127.0.0.1", + "location": "中国", + "browser": "Chrome", + "os": "Windows", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36", + "operator_id": "1", + "operator_name": "admin", + "operator_nickname": "管理员", + "department_id": "1", + "department_name": "超级管理员", + "operation_time": "2025-01-27 21:41:00", + "cost_time": 0.123, + "status": 1 + } + } + + +class OperationLogResult(ListQueryResult): + """ + 操作日志查询结果 + """ + result: list[OperationLogInfo] = Field(default=[], description="操作日志列表") + + +class GetOperationLogResponse(BaseResponse): + """ + 获取操作日志响应 + """ + data: OperationLogResult = Field(default=[], description="操作日志查询结果") diff --git a/schemas/login.py b/schemas/login.py new file mode 100644 index 0000000..b6a3491 --- /dev/null +++ b/schemas/login.py @@ -0,0 +1,185 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/19 01:35 +# @UpdateTime : 2025/01/19 01:35 +# @Author : sonder +# @File : login.py +# @Software : PyCharm +# @Comment : 本程序 +from typing import Optional + +from pydantic import BaseModel, Field +from pydantic_validation_decorator import Network, Size, NotBlank, Xss + +from schemas.common import BaseResponse + + +class LoginParams(BaseModel): + """ + 登录请求模型 + """ + username: str = Field(default="", description="用户名") + password: str = Field(default="", description="密码") + loginDays: Optional[int] = Field(default=1, description="登录天数") + code: Optional[str] = Field(default="", description="验证码") + uuid: Optional[str] = Field(default="", description="验证码UUID") + + +class LoginResult(BaseModel): + """ + 登录响应模型 + """ + accessToken: str = Field(default="", description="访问令牌") + refreshToken: str = Field(default="", description="刷新令牌") + expiresTime: int = Field(default=0, description="令牌过期时间戳") + + +class LoginResponse(BaseResponse): + """ + 登录响应模型 + """ + data: LoginResult = Field(default=None, description="响应数据") + + +class UserInfo(BaseModel): + """ + 用户信息模型 + """ + id: str = Field(default="", description="用户ID") + username: str = Field(default="", description="用户名") + nickname: str = Field(default="", description="用户昵称") + avatar: str = Field(default="", description="用户头像") + gender: int = Field(default=0, description="用户性别") + email: str = Field(default="", description="用户邮箱") + phone: str = Field(default="", description="用户手机号") + status: int = Field(default=0, description="用户状态") + department_id: str = Field(default="", description="用户部门ID") + department_name: str = Field(default="", description="用户部门名称") + roles: list = Field(default=[], description="用户角色") + permissions: list = Field(default=[], description="用户权限") + create_time: str = Field(default="", description="创建时间") + update_time: str = Field(default="", description="更新时间") + + @Xss(field_name='username', message='用户账号不能包含脚本字符') + @NotBlank(field_name='username', message='用户账号不能为空') + @Size(field_name='username', min_length=0, max_length=30, message='用户账号长度不能超过30个字符') + def get_user_name(self): + return self.user_name + + @Xss(field_name='nickname', message='用户昵称不能包含脚本字符') + @Size(field_name='nickname', min_length=0, max_length=30, message='用户昵称长度不能超过30个字符') + def get_nick_name(self): + return self.nick_name + + @Network(field_name='email', field_type='EmailStr', message='邮箱格式不正确') + @Size(field_name='email', min_length=0, max_length=50, message='邮箱长度不能超过50个字符') + def get_email(self): + return self.email + + class Config: + json_schema_extra = { + "example": { + "id": "1", + "username": "admin", + "nickname": "管理员", + "avatar": "https://www.example.com/avatar.jpg", + "gender": 1, + "email": "admin@example.com", + "phone": "1234567890", + "status": 1, + "department_id": "1", + "department_name": "技术部", + "roles": ["admin"], + "permissions": ["read", "write"], + "create_time": "2022-01-01 00:00:00", + "update_time": "2022-01-01 00:00:00" + } + } + + +class GetUserInfoResponse(BaseResponse): + """ + 获取用户信息响应模型 + """ + data: UserInfo = Field(default=None, description="响应数据") + + +class UpdateUserTokenResponse(BaseResponse): + """ + 更新用户令牌响应模型 + """ + data: LoginResult = Field(default=None, description="响应数据") + + +class GetCaptchaResult(BaseModel): + """ + 获取验证码结果模型 + """ + uuid: str = Field(default="", description="验证码UUID") + captcha: str = Field(default="", description="验证码图片") + + class Config: + json_schema_extra = { + "example": { + "uuid": "1234567890", + "captcha": "base64编码的图片" + } + } + + +class GetEmailCodeParams(BaseModel): + """ + 获取邮箱验证码请求模型 + """ + username:str=Field(default="", description="用户名") + title: str = Field(default="注册", description="邮件类型") + mail: str = Field(default="", description="邮箱地址") + + @Network(field_name='mail', field_type='EmailStr', message='邮箱格式不正确') + @Size(field_name='mail', min_length=0, max_length=50, message='邮箱长度不能超过50个字符') + def get_email(self): + return self.mail + + class Config: + json_schema_extra = { + "example": { + "username": "admin", + "title": "注册", + "mail": "admin@example.com" + } + } + + +class ResetPasswordParams(BaseModel): + """ + 重置密码请求模型 + """ + username: str = Field(default="", description="用户名") + mail: str = Field(default="", description="邮箱地址") + code: str = Field(default="", description="验证码") + password: str = Field(default="", description="新密码") + + @Network(field_name='mail', field_type='EmailStr', message='邮箱格式不正确') + @Size(field_name='mail', min_length=0, max_length=50, message='邮箱长度不能超过50个字符') + def get_email(self): + return self.mail + + @Size(field_name='code', min_length=0, max_length=4, message='验证码长度不能超过4个字符') + def get_code(self): + return self.code + + class Config: + json_schema_extra = { + "example": { + "username": "admin", + "mail": "admin@example.com", + "code": "1234", + "password": "123456" + } + } + + +class GetCaptchaResponse(BaseResponse): + """ + 获取验证码响应模型 + """ + data: GetCaptchaResult = Field(default=None, description="响应数据") diff --git a/schemas/permission.py b/schemas/permission.py new file mode 100644 index 0000000..0275f3a --- /dev/null +++ b/schemas/permission.py @@ -0,0 +1,152 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/20 20:32 +# @UpdateTime : 2025/01/20 20:32 +# @Author : sonder +# @File : permission.py +# @Software : PyCharm +# @Comment : 本程序 +from datetime import datetime +from typing import Optional, List + +from pydantic import BaseModel, Field + +from schemas.common import BaseResponse, ListQueryResult + + +class PermissionInfo(BaseModel): + """ + 权限表基础模型。 + """ + id: str = Field(..., description="主键") + create_by: str = Field(default="", description="创建者") + create_time: Optional[datetime] = Field(default=None, description="创建时间") + update_by: str = Field(default="", description="更新者") + update_time: Optional[datetime] = Field(default=None, description="更新时间") + menu_type: int = Field(..., description="菜单类型(0菜单、1iframe、2外链、3按钮)") + parent_id: str = Field(default="", max_length=36, description="父权限ID") + title: str = Field(..., max_length=255, description="菜单名称") + name: Optional[str] = Field(default=None, max_length=255, description="路由名称") + path: Optional[str] = Field(default=None, max_length=255, description="路由路径") + component: Optional[str] = Field(default=None, max_length=255, description="组件路径") + rank: int = Field(default=1, description="菜单排序") + redirect: Optional[str] = Field(default=None, max_length=255, description="路由重定向") + icon: Optional[str] = Field(default=None, max_length=255, description="菜单图标") + extra_icon: Optional[str] = Field(default=None, max_length=255, description="右侧图标") + enter_transition: Optional[str] = Field(default=None, max_length=255, description="进场动画") + leave_transition: Optional[str] = Field(default=None, max_length=255, description="离场动画") + active_path: Optional[str] = Field(default=None, max_length=255, description="菜单激活路径") + auths: Optional[str] = Field(default=None, max_length=255, description="权限标识") + frame_src: Optional[str] = Field(default=None, max_length=255, description="iframe链接地址") + frame_loading: bool = Field(default=True, description="iframe加载动画") + keep_alive: bool = Field(default=False, description="缓存页面") + hidden_tag: bool = Field(default=False, description="隐藏标签页") + fixed_tag: bool = Field(default=False, description="固定标签页") + show_link: bool = Field(default=True, description="显示菜单") + show_parent: bool = Field(default=True, description="显示父级菜单") + + class Config: + json_schema_extra = { + "example": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "create_by": "admin", + "create_time": "2023-10-01T12:00:00", + "update_by": "admin", + "update_time": "2023-10-01T12:00:00", + "menu_type": 0, + "parent_id": "", + "title": "首页", + "name": "home", + "path": "/home", + "component": "HomeView", + "rank": 1, + "redirect": None, + "icon": "home", + "extra_icon": None, + "enter_transition": None, + "leave_transition": None, + "active_path": None, + "auths": None, + "frame_src": None, + "frame_loading": True, + "keep_alive": False, + "hidden_tag": False, + "fixed_tag": False, + "show_link": True, + "show_parent": True + } + } + + +class GetPermissionInfoResponse(BaseResponse): + """ + 获取权限信息响应模型。 + """ + data: PermissionInfo = Field(default=None, description="响应数据") + + +class AddPermissionParams(BaseModel): + """ + 添加权限参数模型。 + """ + name: str = Field(..., max_length=255, description="路由名称") + path: str = Field(..., max_length=255, description="路由路径") + title: str = Field(..., max_length=255, description="菜单名称") + component: Optional[str] = Field(default=None, max_length=255, description="组件路径") + rank: int = Field(default=1, description="菜单排序") + redirect: Optional[str] = Field(default=None, max_length=255, description="路由重定向") + icon: Optional[str] = Field(default=None, max_length=255, description="菜单图标") + extra_icon: Optional[str] = Field(default=None, max_length=255, description="右侧图标") + enter_transition: Optional[str] = Field(default=None, max_length=255, description="进场动画") + leave_transition: Optional[str] = Field(default=None, max_length=255, description="离场动画") + active_path: Optional[str] = Field(default=None, max_length=255, description="菜单激活路径") + auths: Optional[str] = Field(default=None, max_length=255, description="权限标识") + frame_src: Optional[str] = Field(default=None, max_length=255, description="iframe链接地址") + frame_loading: bool = Field(default=True, description="iframe加载动画") + keep_alive: bool = Field(default=False, description="缓存页面") + hidden_tag: bool = Field(default=False, description="隐藏标签页") + fixed_tag: bool = Field(default=False, description="固定标签页") + show_link: bool = Field(default=True, description="显示菜单") + show_parent: bool = Field(default=True, description="显示父级菜单") + parent_id: str = Field(default="", max_length=36, description="父级菜单ID") + menu_type: int = Field(default=0, description="菜单类型") + + class Config: + json_schema_extra = { + "example": { + "name": "home", + "path": "/home", + "title": "首页", + "component": "HomeView", + "rank": 1, + "redirect": None, + "icon": "home", + "extra_icon": None, + "enter_transition": None, + "leave_transition": None, + "active_path": None, + "auths": None, + "frame_src": None, + "frame_loading": True, + "keep_alive": False, + "hidden_tag": False, + "fixed_tag": False, + "show_link": True, + "show_parent": True, + "parent_id": "", + "menu_type": 0 + } + } + + +class GetPermissionListResult(ListQueryResult): + """ + 获取权限列表结果模型。 + """ + result: List[PermissionInfo] = Field(default=[], description="权限列表") + + +class GetPermissionListResponse(BaseResponse): + """ + 获取权限列表响应模型。 + """ + data: GetPermissionListResult = Field(default=None, description="响应数据") diff --git a/schemas/role.py b/schemas/role.py new file mode 100644 index 0000000..992879e --- /dev/null +++ b/schemas/role.py @@ -0,0 +1,169 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/20 22:40 +# @UpdateTime : 2025/01/20 22:40 +# @Author : sonder +# @File : role.py +# @Software : PyCharm +# @Comment : 本程序 +from datetime import datetime +from typing import Optional, List + +from pydantic import BaseModel, Field + +from schemas.common import BaseResponse, ListQueryResult + + +class RoleInfo(BaseModel): + """ + 角色表基础模型。 + """ + id: str = Field(..., description="主键") + create_by: str = Field(default="", description="创建者") + create_time: Optional[datetime] = Field(default=None, description="创建时间") + update_by: str = Field(default="", description="更新者") + update_time: Optional[datetime] = Field(default=None, description="更新时间") + name: str = Field(..., max_length=255, description="角色名称") + code: str = Field(..., max_length=255, description="角色编码") + status: int = Field(default=1, description="状态") + description: Optional[str] = Field(default=None, max_length=255, description="角色描述") + department_id: Optional[str] = Field(default=None, max_length=36, description="所属部门ID") + department_name: Optional[str] = Field(default=None, max_length=255, description="所属部门名称") + department_phone: Optional[str] = Field(default=None, max_length=255, description="所属部门电话") + department_principal: Optional[str] = Field(default=None, max_length=255, description="所属部门领导人") + department_email: Optional[str] = Field(default=None, max_length=255, description="所属部门邮件") + + class Config: + json_schema_extra = { + "example": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "create_by": "admin", + "create_time": "2023-10-01T12:00:00", + "update_by": "admin", + "update_time": "2023-10-01T12:00:00", + "name": "管理员", + "code": "admin", + "status": 1, + "description": "系统管理员角色", + "department_id": "770e8400-e29b-41d4-a716-446655440000", + "department_name": "技术部", + "department_phone": "1234567890", + "department_principal": "张三", + "department_email": "zhangsan@example.com" + } + } + + +class GetRoleInfoResponse(BaseResponse): + """ + 获取角色信息响应模型。 + """ + data: RoleInfo = Field(default=None, description="角色信息") + + +class AddRoleParams(BaseModel): + """ + 添加角色请求参数模型。 + """ + name: str = Field(..., max_length=255, description="角色名称") + code: str = Field(..., max_length=255, description="角色编码") + description: Optional[str] = Field(default=None, max_length=255, description="角色描述") + department_id: Optional[str] = Field(default=None, max_length=36, description="所属部门ID") + status: Optional[int] = Field(default=1, description="状态") + + class Config: + json_schema_extra = { + "example": { + "name": "管理员", + "code": "admin", + "description": "系统管理员角色", + "department_id": "770e8400-e29b-41d4-a716-446655440000", + "status": 1 + } + } + + +class GetRoleListResult(ListQueryResult): + """ + 获取角色列表结果模型。 + """ + result: List[RoleInfo] = Field(default=None, description="角色列表") + + +class GetRoleListResponse(BaseResponse): + """ + 获取角色列表响应模型。 + """ + data: GetRoleListResult = Field(default=None, description="角色列表结果") + + +class AddRolePermissionParams(BaseModel): + """ + 添加角色权限请求参数模型。 + """ + permission_ids: List[str] = Field(..., description="权限ID列表") + + class Config: + json_schema_extra = { + "example": { + "permission_ids": ["1", "2", "3"] + } + } + + +class RolePermissionInfo(BaseModel): + """ + 角色权限信息模型。 + """ + id: str = Field(..., description="主键") + create_by: str = Field(default="", description="创建者") + create_time: Optional[datetime] = Field(default=None, description="创建时间") + update_by: str = Field(default="", description="更新者") + update_time: Optional[datetime] = Field(default=None, description="更新时间") + role_id: str = Field(..., max_length=36, description="角色ID") + role_code: str = Field(..., max_length=255, description="角色编码") + role_name: str = Field(..., max_length=255, description="角色名称") + permission_id: str = Field(..., max_length=36, description="权限ID") + permission__parent_id: str = Field(default="", max_length=36, description="父级权限ID") + permission_name: str = Field(..., max_length=255, description="权限名称") + permission_code: str = Field(..., max_length=255, description="权限编码") + permission_type: str = Field(..., max_length=255, description="权限类型") + + class Config: + json_schema_extra = { + "example": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "create_by": "admin", + "create_time": "2023-10-01T12:00:00", + "update_by": "admin", + "update_time": "2023-10-01T12:00:00", + "role_id": "550e8400-e29b-41d4-a716-446655440000", + "role_code": "admin", + "role_name": "管理员", + "permission_id": "550e8400-e29b-41d4-a716-446655440000", + "permission__parent_id": "550e8400-e29b-41d4-a716-446655440000", + "permission_name": "权限名称", + "permission_code": "admin", + "permission_type": "admin" + } + } + + +class GetRolePermissionInfoResponse(BaseResponse): + """ + 获取角色权限信息响应模型。 + """ + data: RolePermissionInfo = Field(default=None, description="角色权限信息") + + +class GetRolePermissionListResult(ListQueryResult): + """ + 获取角色权限列表结果模型。 + """ + result: List[RolePermissionInfo] = Field(default=None, description="角色权限列表") + + +class GetRolePermissionListResponse(BaseResponse): + """ + 获取角色权限列表响应模型。 + """ + data: GetRolePermissionListResult = Field(default=None, description="角色权限列表结果") diff --git a/schemas/server.py b/schemas/server.py new file mode 100644 index 0000000..2304764 --- /dev/null +++ b/schemas/server.py @@ -0,0 +1,98 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/02/04 15:26 +# @UpdateTime : 2025/02/04 15:26 +# @Author : sonder +# @File : server.py +# @Software : PyCharm +# @Comment : 本程序 +from typing import Optional, List + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from schemas.common import BaseResponse + + +class CpuInfo(BaseModel): + """ + CPU信息 + """ + model_config = ConfigDict(alias_generator=to_camel) + + cpu_num: Optional[int] = Field(default=None, description='核心数') + used: Optional[float] = Field(default=None, description='CPU用户使用率') + sys: Optional[float] = Field(default=None, description='CPU系统使用率') + free: Optional[float] = Field(default=None, description='CPU当前空闲率') + + +class MemoryInfo(BaseModel): + """ + 内存信息 + """ + model_config = ConfigDict(alias_generator=to_camel) + + total: Optional[str] = Field(default=None, description='内存总量') + used: Optional[str] = Field(default=None, description='已用内存') + free: Optional[str] = Field(default=None, description='剩余内存') + usage: Optional[float] = Field(default=None, description='使用率') + + +class SystemInfo(BaseModel): + """ + 系统信息 + """ + model_config = ConfigDict(alias_generator=to_camel) + + computer_ip: Optional[str] = Field(default=None, description='服务器IP') + computer_name: Optional[str] = Field(default=None, description='服务器名称') + os_arch: Optional[str] = Field(default=None, description='系统架构') + os_name: Optional[str] = Field(default=None, description='操作系统') + user_dir: Optional[str] = Field(default=None, description='项目路径') + + +class PythonInfo(MemoryInfo): + """ + Python信息 + """ + model_config = ConfigDict(alias_generator=to_camel) + + name: Optional[str] = Field(default=None, description='Python名称') + version: Optional[str] = Field(default=None, description='Python版本') + start_time: Optional[str] = Field(default=None, description='启动时间') + run_time: Optional[str] = Field(default=None, description='运行时长') + home: Optional[str] = Field(default=None, description='安装路径') + + +class SystemFiles(BaseModel): + """ + 系统磁盘信息 + """ + model_config = ConfigDict(alias_generator=to_camel) + + dir_name: Optional[str] = Field(default=None, description='盘符路径') + sys_type_name: Optional[str] = Field(default=None, description='盘符类型') + type_name: Optional[str] = Field(default=None, description='文件类型') + total: Optional[str] = Field(default=None, description='总大小') + used: Optional[str] = Field(default=None, description='已经使用量') + free: Optional[str] = Field(default=None, description='剩余大小') + usage: Optional[str] = Field(default=None, description='资源的使用率') + + +class GetSystemInfoResult(BaseModel): + """ + 获取系统信息结果 + """ + model_config = ConfigDict(alias_generator=to_camel) + + cpu: Optional[CpuInfo] = Field(description='CPU相关信息') + python: Optional[PythonInfo] = Field(description='Python相关信息') + memory: Optional[MemoryInfo] = Field(description='內存相关信息') + system: Optional[SystemInfo] = Field(description='服务器相关信息') + system_files: Optional[List[SystemFiles]] = Field(description='磁盘相关信息') + + +class GetServerInfoResponse(BaseResponse): + """ + 获取服务器信息响应 + """ + data: GetSystemInfoResult = Field(default={}, description="服务器信息查询结果") diff --git a/schemas/user.py b/schemas/user.py new file mode 100644 index 0000000..abc3377 --- /dev/null +++ b/schemas/user.py @@ -0,0 +1,350 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/19 02:10 +# @UpdateTime : 2025/01/19 02:10 +# @Author : sonder +# @File : user.py +# @Software : PyCharm +# @Comment : 本程序 +from datetime import datetime +from typing import Optional, List +from uuid import UUID + +from pydantic import BaseModel, Field +from pydantic_validation_decorator import Xss, NotBlank, Size, Network + +from schemas.common import BaseResponse, ListQueryResult + + +class UserBase(BaseModel): + """ + 用户表基础模型。 + """ + id: UUID = Field(..., description="主键") + del_flag: int = Field(default=1, description="删除标志 1存在 0删除") + create_by: str = Field(default="", description="创建者") + create_time: str = Field(..., description="创建时间") + update_by: str = Field(default="", description="更新者") + update_time: str = Field(..., description="更新时间") + username: str = Field(..., max_length=255, description="用户名") + password: str = Field(..., max_length=255, description="密码") + email: Optional[str] = Field(default=None, max_length=255, description="邮箱") + phone: Optional[str] = Field(default=None, max_length=30, description="手机号") + nickname: Optional[str] = Field(default=None, max_length=255, description="昵称") + avatar: Optional[str] = Field(default=None, max_length=255, description="头像") + gender: int = Field(default=0, description="性别(1男,0女)") + status: int = Field(default=1, description="用户状态(1启用,0禁用)") + department_id: Optional[str] = Field(default=None, max_length=36, description="所属部门") + + @Xss(field_name='username', message='用户账号不能包含脚本字符') + @NotBlank(field_name='username', message='用户账号不能为空') + @Size(field_name='username', min_length=0, max_length=30, message='用户账号长度不能超过30个字符') + def get_user_name(self): + return self.user_name + + @Xss(field_name='nickname', message='用户昵称不能包含脚本字符') + @Size(field_name='nickname', min_length=0, max_length=30, message='用户昵称长度不能超过30个字符') + def get_nick_name(self): + return self.nick_name + + @Network(field_name='email', field_type='EmailStr', message='邮箱格式不正确') + @Size(field_name='email', min_length=0, max_length=50, message='邮箱长度不能超过50个字符') + def get_email(self): + return self.email + + class Config: + json_schema_extra = { + "example": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "del_flag": 1, + "create_by": "admin", + "create_time": "2025-01-19 02:10:00", + "update_by": "admin", + "update_time": "2025-01-19 02:10:00", + "username": "admin", + "password": "admin", + "email": "admin@example.com", + "phone": "13800138000", + "nickname": "管理员", + "gender": 1, + "status": 1, + "department_id": "550e8400-e29b-41d4-a716-446655440000" + } + } + + +class UserInfo(BaseModel): + """ + 用户表基础模型。 + """ + id: str = Field(..., description="主键") + create_time: str = Field(..., description="创建时间") # 使用蛇形命名 + update_time: str = Field(..., description="更新时间") # 使用蛇形命名 + username: str = Field(..., max_length=255, description="用户名") + email: Optional[str] = Field(default=None, max_length=255, description="邮箱") + phone: Optional[str] = Field(default=None, max_length=30, description="手机号") + nickname: Optional[str] = Field(default=None, max_length=255, description="昵称") + avatar: Optional[str] = Field(default=None, max_length=255, description="头像") + gender: int = Field(default=0, description="性别(1男,0女)") + status: int = Field(default=1, description="用户状态(1启用,0禁用)") + department_id: Optional[str] = Field(default=None, max_length=36, description="所属部门") + + class Config: + json_schema_extra = { + "example": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "create_time": "2025-01-19 02:10:00", + "update_time": "2025-01-19 02:10:00", + "username": "admin", + "email": "admin@example.com", + "phone": "13800138000", + "nickname": "管理员", + "gender": 1, + "status": 1, + "department_id": "550e8400-e29b-41d4-a716-446655440000" + } + } + + +class GetUserInfoResponse(BaseResponse): + """ + 获取用户信息响应模型。 + """ + data: UserInfo = Field(default=None, description="响应数据") + + +class AddUserParams(BaseModel): + """ + 添加用户参数模型。 + """ + username: str = Field(..., max_length=255, description="用户名") + password: str = Field(..., max_length=255, description="密码") + email: Optional[str] = Field(default=None, max_length=255, description="邮箱") + phone: Optional[str] = Field(default=None, max_length=30, description="手机号") + nickname: Optional[str] = Field(default=None, max_length=255, description="昵称") + avatar: Optional[str] = Field(default=None, max_length=255, description="头像") + gender: int = Field(default=0, description="性别(1男,0女)") + status: int = Field(default=1, description="用户状态(1启用,0禁用)") + department_id: Optional[str] = Field(default=None, max_length=36, description="所属部门") + + @Xss(field_name='username', message='用户账号不能包含脚本字符') + @NotBlank(field_name='username', message='用户账号不能为空') + @Size(field_name='username', min_length=0, max_length=30, message='用户账号长度不能超过30个字符') + def get_user_name(self): + return self.user_name + + @Xss(field_name='nickname', message='用户昵称不能包含脚本字符') + @Size(field_name='nickname', min_length=0, max_length=30, message='用户昵称长度不能超过30个字符') + def get_nick_name(self): + return self.nick_name + + @Network(field_name='email', field_type='EmailStr', message='邮箱格式不正确') + @Size(field_name='email', min_length=0, max_length=50, message='邮箱长度不能超过50个字符') + def get_email(self): + return self.email + + class Config: + json_schema_extra = { + "example": { + "username": "admin", + "password": "admin", + "email": "admin@example.com", + "phone": "13800138000", + "nickname": "管理员", + "gender": 1, + "status": 1, + "department_id": "550e8400-e29b-41d4-a716-446655440000" + } + } + + +class RegisterUserParams(AddUserParams): + """ + 注册用户参数模型。 + """ + code: str = Field(..., max_length=10, description="验证码") + + class Config: + json_schema_extra = { + "example": { + "username": "admin", + "password": "admin", + "email": "admin@example.com", + "phone": "13800138000", + "nickname": "管理员", + "gender": 1, + "status": 1, + "department_id": "550e8400-e29b-41d4-a716-446655440000", + "code": "123456" + } + } + + +class UpdateUserParams(BaseModel): + """ + 更新用户参数模型。 + """ + username: str = Field(..., max_length=255, description="用户名") + email: Optional[str] = Field(default=None, max_length=255, description="邮箱") + phone: Optional[str] = Field(default=None, max_length=30, description="手机号") + nickname: Optional[str] = Field(default=None, max_length=255, description="昵称") + avatar: Optional[str] = Field(default=None, max_length=255, description="头像") + gender: int = Field(default=0, description="性别(1男,0女)") + status: int = Field(default=1, description="用户状态(1启用,0禁用)") + department_id: Optional[str] = Field(default=None, max_length=36, description="所属部门") + + @Xss(field_name='username', message='用户账号不能包含脚本字符') + @NotBlank(field_name='username', message='用户账号不能为空') + @Size(field_name='username', min_length=0, max_length=30, message='用户账号长度不能超过30个字符') + def get_user_name(self): + return self.user_name + + @Xss(field_name='nickname', message='用户昵称不能包含脚本字符') + @Size(field_name='nickname', min_length=0, max_length=30, message='用户昵称长度不能超过30个字符') + def get_nick_name(self): + return self.nick_name + + @Network(field_name='email', field_type='EmailStr', message='邮箱格式不正确') + @Size(field_name='email', min_length=0, max_length=50, message='邮箱长度不能超过50个字符') + def get_email(self): + return self.email + + class Config: + json_schema_extra = { + "example": { + "username": "admin", + "email": "admin@example.com", + "phone": "13800138000", + "nickname": "管理员", + "gender": 1, + "status": 1, + "department_id": "550e8400-e29b-41d4-a716-446655440000" + } + } + + +class GetUserListResult(ListQueryResult): + """ + 获取用户列表结果模型。 + """ + result: List[UserInfo] = Field(default=[], description="部门列表") + + +class GetUserListResponse(BaseResponse): + """ + 获取用户列表响应模型。 + """ + data: GetUserListResult = Field(default=None, description="响应数据") + + +class AddUserRoleParams(BaseModel): + """ + 添加用户角色参数模型。 + """ + user_id: str = Field(..., max_length=36, description="用户ID") + role_id: str = Field(default=[], max_length=36, description="角色ID") + + class Config: + json_schema_extra = { + "example": { + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "role_ids": "550e8400-e29b-41d4-a716-446655440000" + } + } + + +class UpdateUserRoleParams(BaseModel): + """ + 更新用户角色参数模型。 + """ + user_id: str = Field(..., max_length=36, description="用户ID") + role_ids: List[str] = Field(default=[], max_length=36, description="角色ID") + + class Config: + json_schema_extra = { + "example": { + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "role_ids": ["550e8400-e29b-41d4-a716-446655440000"] + } + } + + +class UserRoleInfo(BaseModel): + """ + 用户角色信息模型。 + """ + id: str = Field(..., max_length=36, description="主键ID") + user_id: str = Field(..., max_length=36, description="用户ID") + user_name: str = Field(..., max_length=100, description="用户账号") + role_name: str = Field(..., max_length=100, description="角色名称") + role_code: str = Field(..., max_length=100, description="角色编码") + role_id: str = Field(..., max_length=36, description="角色ID") + create_time: datetime = Field(..., description="创建时间") + update_time: datetime = Field(..., description="更新时间") + + @Xss(field_name='user_name', message='用户账号不能包含脚本字符') + @NotBlank(field_name='user_name', message='用户账号不能为空') + @Size(field_name='user_name', min_length=0, max_length=30, message='用户账号长度不能超过30个字符') + def get_user_name(self): + return self.user_name + + @Xss(field_name='role_name', message='角色名称不能包含脚本字符') + @NotBlank(field_name='role_name', message='角色名称不能为空') + @Size(field_name='role_name', min_length=0, max_length=30, message='角色名称长度不能超过30个字符') + def get_role_name(self): + return self.role_name + + class Config: + json_schema_extra = { + "example": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "user_name": "zhangsan", + "role_name": "管理员", + "role_code": "admin", + "role_id": "550e8400-e29b-41d4-a716-446655440000", + "create_time": "2023-10-01T12:00:00", + "update_time": "2023-10-01T12:00:00" + } + } + + +class GetUserRoleInfoResponse(BaseResponse): + """ + 获取用户角色信息响应模型。 + """ + data: UserRoleInfo = Field(default=None, description="响应数据") + + +class GetUserRoleListResult(ListQueryResult): + """ + 获取用户角色列表结果模型。 + """ + result: List[UserRoleInfo] = Field(default=[], description="用户角色列表") + + +class GetUserRoleListResponse(BaseResponse): + """ + 获取用户角色列表响应模型。 + """ + data: GetUserRoleListResult = Field(default=None, description="响应数据") + + +class GetUserPermissionListResponse(BaseResponse): + """ + 获取用户权限列表响应模型。 + """ + data: List[str] = Field(default=[], description="响应数据") + + +class ResetPasswordParams(BaseModel): + """ + 重置密码参数模型。 + """ + password: str = Field(..., max_length=100, description="新密码") + + class Config: + json_schema_extra = { + "example": { + "password": "123456" + } + } diff --git a/templates/mail_en.html b/templates/mail_en.html new file mode 100644 index 0000000..652b3d7 --- /dev/null +++ b/templates/mail_en.html @@ -0,0 +1,147 @@ + + + + + Email Verification Code + + + + + + + + + +
+
+ + +
+
+
+
+ Dear User: Hello! + + You are performing {{ TITLE }} operation. Please enter the following verification code to complete the operation: + +
+ {{ CODE }} +
+
+
+ + Note: This operation may modify your password, login email, or bound phone number. If this is not your operation, please log in and change your password to ensure account security. +
(Staff will not ask you for this verification code. Please do not disclose it!) +
+
+
+
+
+

This is a system email. Please do not reply.
+ Please keep your email safe to avoid account theft. +

+

——{{ PROJECTNAME }}

+
+
+
+ + \ No newline at end of file diff --git a/templates/mail_zh.html b/templates/mail_zh.html new file mode 100644 index 0000000..c650431 --- /dev/null +++ b/templates/mail_zh.html @@ -0,0 +1,147 @@ + + + + + 邮箱验证码 + + + + + + + + + +
+
+ + +
+
+
+
+ 尊敬的用户:您好! + + 您正在进行{{ TITLE }}操作,请在验证码中输入以下验证码完成操作: + +
+ {{ CODE }} +
+
+
+ + 注意:此操作可能会修改您的密码、登录邮箱或绑定手机。如非本人操作,请及时登录并修改密码以保证帐户安全 +
(工作人员不会向你索取此验证码,请勿泄漏!) +
+
+
+
+
+

此为系统邮件,请勿回复
+ 请保管好您的邮箱,避免账号被他人盗用 +

+

——{{ PROJECTNAME }}

+
+
+
+ + \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..12e9ed0 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,7 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 01:59 +# @UpdateTime : 2025/01/18 01:59 +# @Author : sonder +# @File : __init__.py.py +# @Software : PyCharm +# @Comment : 本程序 diff --git a/utils/captcha.py b/utils/captcha.py new file mode 100644 index 0000000..4ef2702 --- /dev/null +++ b/utils/captcha.py @@ -0,0 +1,112 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/26 20:59 +# @UpdateTime : 2025/01/26 20:59 +# @Author : sonder +# @File : captcha.py +# @Software : PyCharm +# @Comment : 本程序 +import base64 +import io +import os +import random +import string + +from PIL import Image, ImageDraw, ImageFont + + +class Captcha: + """ + 验证码类 + """ + + @classmethod + async def create_captcha(cls, captcha_type: str = "0"): + """ + 生成验证码 + :param captcha_type: 验证码类型,0为数字,1为数字和字母 + :return: 验证码图片和验证码base64字符串 + """ + # 创建空白图像 + image = Image.new('RGB', (120, 40), color='#EAEAEA') + draw = ImageDraw.Draw(image) + + # 设置字体 + font_path = os.path.join(os.path.abspath(os.getcwd()), 'assets', 'font', 'MiSans-Medium.ttf') + font = ImageFont.truetype(font_path, size=25) + + if captcha_type == '0': + # 生成两个0-9之间的随机整数 + num1 = random.randint(0, 9) + num2 = random.randint(0, 9) + # 从运算符列表中随机选择一个 + operational_character_list = ['+', '-', '*'] + operational_character = random.choice(operational_character_list) + # 根据选择的运算符进行计算 + if operational_character == '+': + result = num1 + num2 + elif operational_character == '-': + result = num1 - num2 + else: + result = num1 * num2 + # 生成算术题文本 + text = f'{num1} {operational_character} {num2} = ?' + # 计算文本宽度以居中显示 + text_width = draw.textlength(text, font=font) + x = (120 - text_width) / 2 + draw.text((x, 5), text, fill='blue', font=font) + else: + # 生成随机字母和数字组合 + result = ''.join(random.choices(string.ascii_letters + string.digits, k=4)) + # 绘制每个字符,并添加随机旋转和倾斜 + x = 10 + for char in result: + # 创建单个字符的图像 + char_image = Image.new('RGBA', (25, 40), color=(234, 234, 234, 0)) + char_draw = ImageDraw.Draw(char_image) + char_draw.text((0, 0), char, font=font, fill=(0, 0, 255)) + # 随机旋转字符 + char_image = char_image.rotate(random.randint(-40, 40), expand=1) + # 随机倾斜字符 + char_image = char_image.transform(char_image.size, Image.AFFINE, + (1, random.uniform(-0.3, 0.3), 0, 0, 1, 0)) + # 将字符粘贴到主图像上 + image.paste(char_image, (x, 0), char_image) + x += 25 + # 添加干扰元素 + cls._add_noise(image) + cls._add_lines(image) + + # 将图像数据保存到内存中 + buffer = io.BytesIO() + image.save(buffer, format='PNG') + + # 将图像数据转换为base64字符串 + base64_string = base64.b64encode(buffer.getvalue()).decode() + + return [base64_string, result] + + @staticmethod + def _add_noise(image): + """ + 添加噪点干扰 + """ + draw = ImageDraw.Draw(image) + for _ in range(150): # 添加100个噪点 + x = random.randint(0, 120) + y = random.randint(0, 40) + draw.point((x, y), fill=(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))) + + @staticmethod + def _add_lines(image): + """ + 添加干扰线 + """ + draw = ImageDraw.Draw(image) + for _ in range(10): # 添加5条干扰线 + x1 = random.randint(0, 120) + y1 = random.randint(0, 40) + x2 = random.randint(0, 120) + y2 = random.randint(0, 40) + draw.line((x1, y1, x2, y2), + fill=(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)), + width=1) diff --git a/utils/common.py b/utils/common.py new file mode 100644 index 0000000..ccef965 --- /dev/null +++ b/utils/common.py @@ -0,0 +1,36 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/19 00:52 +# @UpdateTime : 2025/01/19 00:52 +# @Author : sonder +# @File : common.py +# @Software : PyCharm +# @Comment : 本程序 + +def bytes2human(n, format_str='%(value).1f%(symbol)s'): + """Used by various scripts. See: + http://goo.gl/zeJZl + + >>> bytes2human(10000) + '9.8K' + >>> bytes2human(100001221) + '95.4M' + """ + symbols = ('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB') + prefix = {} + for i, s in enumerate(symbols[1:]): + prefix[s] = 1 << (i + 1) * 10 + for symbol in reversed(symbols[1:]): + if n >= prefix[symbol]: + value = float(n) / prefix[symbol] + return format_str % locals() + return format_str % dict(symbol=symbols[0], value=n) + + +async def filterKeyValues(dataList: list, key: str) -> list: + """ + 获取列表字段数据 + :param dataList: 数据列表 + :param key: 关键字 + :return: + """ + return [item[key] for item in dataList] diff --git a/utils/cron.py b/utils/cron.py new file mode 100644 index 0000000..3c12868 --- /dev/null +++ b/utils/cron.py @@ -0,0 +1,180 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/19 00:53 +# @UpdateTime : 2025/01/19 00:53 +# @Author : sonder +# @File : cron.py +# @Software : PyCharm +# @Comment : 本程序 +import re +from datetime import datetime + + +class Cron: + """ + Cron表达式工具类 + """ + + @classmethod + def __valid_range(cls, search_str: str, start_range: int, end_range: int): + match = re.match(r'^(\d+)-(\d+)$', search_str) + if match: + start, end = int(match.group(1)), int(match.group(2)) + return start_range <= start < end <= end_range + return False + + @classmethod + def __valid_sum( + cls, search_str: str, start_range_a: int, start_range_b: int, end_range_a: int, end_range_b: int, + sum_range: int + ): + match = re.match(r'^(\d+)/(\d+)$', search_str) + if match: + start, end = int(match.group(1)), int(match.group(2)) + return ( + start_range_a <= start <= start_range_b + and end_range_a <= end <= end_range_b + and start + end <= sum_range + ) + return False + + @classmethod + def validate_second_or_minute(cls, second_or_minute: str): + """ + 校验秒或分钟值是否正确 + + :param second_or_minute: 秒或分钟值 + :return: 校验结果 + """ + if ( + second_or_minute == '*' + or ('-' in second_or_minute and cls.__valid_range(second_or_minute, 0, 59)) + or ('/' in second_or_minute and cls.__valid_sum(second_or_minute, 0, 58, 1, 59, 59)) + or re.match(r'^(?:[0-5]?\d|59)(?:,[0-5]?\d|59)*$', second_or_minute) + ): + return True + return False + + @classmethod + def validate_hour(cls, hour: str): + """ + 校验小时值是否正确 + + :param hour: 小时值 + :return: 校验结果 + """ + if ( + hour == '*' + or ('-' in hour and cls.__valid_range(hour, 0, 23)) + or ('/' in hour and cls.__valid_sum(hour, 0, 22, 1, 23, 23)) + or re.match(r'^(?:0|[1-9]|1\d|2[0-3])(?:,(?:0|[1-9]|1\d|2[0-3]))*$', hour) + ): + return True + return False + + @classmethod + def validate_day(cls, day: str): + """ + 校验日值是否正确 + + :param day: 日值 + :return: 校验结果 + """ + if ( + day in ['*', '?', 'L'] + or ('-' in day and cls.__valid_range(day, 1, 31)) + or ('/' in day and cls.__valid_sum(day, 1, 30, 1, 30, 31)) + or ('W' in day and re.match(r'^(?:[1-9]|1\d|2\d|3[01])W$', day)) + or re.match(r'^(?:0|[1-9]|1\d|2[0-9]|3[0-1])(?:,(?:0|[1-9]|1\d|2[0-9]|3[0-1]))*$', day) + ): + return True + return False + + @classmethod + def validate_month(cls, month: str): + """ + 校验月值是否正确 + + :param month: 月值 + :return: 校验结果 + """ + if ( + month == '*' + or ('-' in month and cls.__valid_range(month, 1, 12)) + or ('/' in month and cls.__valid_sum(month, 1, 11, 1, 11, 12)) + or re.match(r'^(?:0|[1-9]|1[0-2])(?:,(?:0|[1-9]|1[0-2]))*$', month) + ): + return True + return False + + @classmethod + def validate_week(cls, week: str): + """ + 校验周值是否正确 + + :param week: 周值 + :return: 校验结果 + """ + if ( + week in ['*', '?'] + or ('-' in week and cls.__valid_range(week, 1, 7)) + or ('#' in week and re.match(r'^[1-7]#[1-4]$', week)) + or ('L' in week and re.match(r'^[1-7]L$', week)) + or re.match(r'^[1-7](?:(,[1-7]))*$', week) + ): + return True + return False + + @classmethod + def validate_year(cls, year: str): + """ + 校验年值是否正确 + + :param year: 年值 + :return: 校验结果 + """ + current_year = int(datetime.now().year) + future_years = [current_year + i for i in range(9)] + if ( + year == '*' + or ('-' in year and cls.__valid_range(year, current_year, 2099)) + or ('/' in year and cls.__valid_sum(year, current_year, 2098, 1, 2099 - current_year, 2099)) + or ('#' in year and re.match(r'^[1-7]#[1-4]$', year)) + or ('L' in year and re.match(r'^[1-7]L$', year)) + or ( + (len(year) == 4 or ',' in year) + and all(int(item) in future_years and current_year <= int(item) <= 2099 for item in year.split(',')) + ) + ): + return True + return False + + @classmethod + def validate_cron_expression(cls, cron_expression: str): + """ + 校验Cron表达式是否正确 + + :param cron_expression: Cron表达式 + :return: 校验结果 + """ + values = cron_expression.split() + if len(values) != 6 and len(values) != 7: + return False + second_validation = cls.validate_second_or_minute(values[0]) + minute_validation = cls.validate_second_or_minute(values[1]) + hour_validation = cls.validate_hour(values[2]) + day_validation = cls.validate_day(values[3]) + month_validation = cls.validate_month(values[4]) + week_validation = cls.validate_week(values[5]) + validation = ( + second_validation + and minute_validation + and hour_validation + and day_validation + and month_validation + and week_validation + ) + if len(values) == 6: + return validation + if len(values) == 7: + year_validation = cls.validate_year(values[6]) + return validation and year_validation diff --git a/utils/log.py b/utils/log.py new file mode 100644 index 0000000..5b94b0d --- /dev/null +++ b/utils/log.py @@ -0,0 +1,124 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 02:08 +# @UpdateTime : 2025/01/18 02:08 +# @Author : sonder +# @File : log.py +# @Software : PyCharm +# @Comment : 本程序 +import os +import sys +import time + +from loguru import logger + +# 日志存储目录 +log_path = os.path.join(os.getcwd(), 'logs') +if not os.path.exists(log_path): + os.makedirs(log_path) # 如果目录不存在,则创建 + +# 按天创建日志目录 +daily_log_path = os.path.join(log_path, time.strftime("%Y-%m-%d")) +if not os.path.exists(daily_log_path): + os.makedirs(daily_log_path) + +# 定义按级别分开的日志文件路径 +log_path_debug = os.path.join(daily_log_path, 'debug.log') +log_path_info = os.path.join(daily_log_path, 'info.log') +log_path_error = os.path.join(daily_log_path, 'error.log') +log_path_warning = os.path.join(daily_log_path, 'warning.log') +log_path_sql = os.path.join(daily_log_path, 'sql.log') # SQL 查询日志文件 + +# 定义合并后的日志文件路径 +log_path_all = os.path.join(daily_log_path, 'all.log') + +# 移除默认的日志处理器 +logger.remove() + +# 添加控制台日志处理器(彩色输出) +logger.add( + sink=sys.stdout, + format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name}:{function}:{line} - {message}", + level="DEBUG", # 控制台输出所有级别的日志 + colorize=True, # 启用彩色输出 + enqueue=True, # 异步写入日志 +) + +# 添加按级别分开的日志文件处理器 +logger.add( + sink=log_path_debug, + format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name}:{function}:{line} - {message}", + level="DEBUG", # 只记录 DEBUG 级别的日志 + rotation="50 MB", # 日志文件大小达到 50MB 时轮换 + retention="30 days", # 日志文件保留 30 天 + compression="zip", # 压缩旧日志文件 + encoding="utf-8", + enqueue=True, # 异步写入日志 + filter=lambda record: record["level"].name == "DEBUG", # 只处理 DEBUG 级别的日志 +) + +logger.add( + sink=log_path_info, + format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name}:{function}:{line} - {message}", + level="INFO", # 只记录 INFO 级别的日志 + rotation="50 MB", + retention="30 days", + compression="zip", + encoding="utf-8", + enqueue=True, + filter=lambda record: record["level"].name == "INFO", # 只处理 INFO 级别的日志 +) + +logger.add( + sink=log_path_warning, + format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name}:{function}:{line} - {message}", + level="WARNING", # 只记录 WARNING 级别的日志 + rotation="50 MB", + retention="30 days", + compression="zip", + encoding="utf-8", + enqueue=True, + filter=lambda record: record["level"].name == "WARNING", # 只处理 WARNING 级别的日志 +) + +logger.add( + sink=log_path_error, + format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name}:{function}:{line} - {message}", + level="ERROR", # 只记录 ERROR 级别的日志 + rotation="50 MB", + retention="30 days", + compression="zip", + encoding="utf-8", + enqueue=True, + filter=lambda record: record["level"].name == "ERROR", # 只处理 ERROR 级别的日志 +) + +# 添加 SQL 查询日志文件处理器 +logger.add( + sink=log_path_sql, + format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name}:{function}:{line} - {message}", + level="DEBUG", # 记录所有 SQL 查询日志 + rotation="50 MB", + retention="30 days", + compression="zip", + encoding="utf-8", + enqueue=True, + filter=lambda record: "tortoise.db_client" in record["name"], # 只处理 SQL 查询日志 +) + +# 添加合并后的日志文件处理器 +logger.add( + sink=log_path_all, + format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name}:{function}:{line} - {message}", + level="DEBUG", # 记录所有级别的日志 + rotation="50 MB", + retention="30 days", + compression="zip", + encoding="utf-8", + enqueue=True, +) + +# 自定义日志颜色 +logger.level("DEBUG", color="") # DEBUG 级别为蓝色 +logger.level("INFO", color="") # INFO 级别为绿色 +logger.level("WARNING", color="") # WARNING 级别为金色 +logger.level("ERROR", color="") # ERROR 级别为红色 diff --git a/utils/mail.py b/utils/mail.py new file mode 100644 index 0000000..c567a3b --- /dev/null +++ b/utils/mail.py @@ -0,0 +1,105 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/26 21:51 +# @UpdateTime : 2025/01/26 21:51 +# @Author : sonder +# @File : mail.py +# @Software : PyCharm +# @Comment : 本程序 +import random +from datetime import timedelta +from email.message import EmailMessage +from email.utils import formataddr + +from aiosmtplib import send +from fastapi import Request +from jinja2 import Environment, FileSystemLoader + +from config.constant import RedisKeyConfig +from config.env import AppConfig, EmailConfig +from utils.log import logger + + +class Email: + """ + 邮件发送类,用于发送邮件。 + """ + + @classmethod + async def generate_verification_code(cls, length: int = 4) -> str: + """ + 随机生成数字验证码 + :param length: 验证码长度 + :return: + """ + + return ''.join(str(random.randint(0, 9)) for _ in range(length)) + + @classmethod + async def send_email(cls, request: Request, username: str, title: str = "注册", mail: str = "") -> bool: + """ + 发送邮件 + :param request: 请求对象 + :param username: 用户账号 + :param title: 邮件标题 + :param mail: 邮箱地址 + """ + code = await cls.generate_verification_code(4) + codeStr = "" + for i in code: + codeStr += f"""""" + env = Environment(loader=FileSystemLoader('templates')) + template = env.get_template('mail_en.html') + content = template.render( + TITLE=title, + CODE=codeStr, + PROJECTNAME=AppConfig.app_name, + ) + subject = f"{AppConfig.app_name}-{title} Verification Code" + sendName = AppConfig.app_name + hostname = EmailConfig.email_host + port = EmailConfig.email_port + message = EmailMessage() + message["From"] = formataddr((sendName, EmailConfig.email_username)) + message["To"] = mail + message["Subject"] = subject + message.set_content(content, subtype="html") + try: + await send( + message, + hostname=hostname, + port=port, + username=EmailConfig.email_username, + password=EmailConfig.email_password + ) + await request.app.state.redis.set(f"{RedisKeyConfig.EMAIL_CODES.key}:{mail}-{username}", code, ex=timedelta(minutes=2)) + logger.info(f"发送邮件至{mail}成功,验证码:{code}") + return True + except Exception as e: + logger.error(e) + return False + + @classmethod + async def verify_code(cls, request: Request, username: str, mail: str, code: str) -> dict: + """ + 验证验证码 + :param request: 请求对象 + :param username: 用户账号 + :param mail: 邮箱地址 + :param code: 验证码 + """ + redis_code = await request.app.state.redis.get(f"{RedisKeyConfig.EMAIL_CODES.key}:{mail}-{username}") + if redis_code is None: + return { + "status": False, + "msg": "验证码已过期" + } + if str(redis_code).lower() == code.lower(): + await request.app.state.redis.delete(f"{RedisKeyConfig.EMAIL_CODES.key}:{mail}-{username}") + return { + "status": True, + "msg": "验证码正确" + } + return { + "status": False, + "msg": "验证码错误" + } diff --git a/utils/password.py b/utils/password.py new file mode 100644 index 0000000..8b42943 --- /dev/null +++ b/utils/password.py @@ -0,0 +1,46 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 02:34 +# @UpdateTime : 2025/01/18 02:34 +# @Author : sonder +# @File : password.py +# @Software : PyCharm +# @Comment : 本程序 +import hashlib + +from config.env import JwtConfig + + +class Password: + """ + 密码工具类 + """ + + @classmethod + async def verify_password(cls, plain_password, hashed_password): + """ + 工具方法:校验当前输入的密码与数据库存储的密码是否一致 + + :param plain_password: 当前输入的密码 + :param hashed_password: 数据库存储的密码 + :return: 校验结果 + """ + salt = JwtConfig.jwt_salt + # 将盐值和密码拼接在一起 + password_with_salt = (salt + plain_password).encode('utf-8') + # 使用SHA256算法对拼接后的密码进行加密 + password_hashed = hashlib.sha256(password_with_salt).hexdigest() + return password_hashed == hashed_password + + @classmethod + async def get_password_hash(cls, input_password: str): + """ + 工具方法:对当前输入的密码进行加密 + + :param input_password: 输入的密码 + :return: 加密成功的密码 + """ + salt = JwtConfig.jwt_salt + # 将盐值和密码拼接在一起 + password_with_salt = (salt + input_password).encode('utf-8') + # 使用SHA256算法对拼接后的密码进行加密 + return hashlib.sha256(password_with_salt).hexdigest() diff --git a/utils/response.py b/utils/response.py new file mode 100644 index 0000000..219979a --- /dev/null +++ b/utils/response.py @@ -0,0 +1,225 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 02:13 +# @UpdateTime : 2025/01/18 02:13 +# @Author : sonder +# @File : response.py +# @Software : PyCharm +# @Comment : 本程序 + +from datetime import datetime +from typing import Any, Dict, Optional + +from fastapi import status +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse, StreamingResponse +from pydantic import BaseModel + +from config.constant import HttpStatusConstant + + +class Response: + """ + 响应工具类,用于统一封装接口返回格式。 + """ + + @classmethod + def _build_response( + cls, + code: int, + msg: str, + data: Optional[Any] = None, + rows: Optional[Any] = None, + dict_content: Optional[Dict] = None, + model_content: Optional[BaseModel] = None, + success: bool = True, + ) -> Dict: + """ + 构建统一的响应结果字典。 + + :param code: 状态码 + :param msg: 响应消息 + :param data: 响应数据 + :param rows: 响应行数据(通常用于分页) + :param dict_content: 自定义字典内容 + :param model_content: 自定义 Pydantic 模型内容 + :param success: 是否成功 + :return: 统一的响应结果字典 + """ + result = { + "code": code, + "msg": msg, + "success": success, + "time": datetime.now().isoformat(), # 添加时间戳 + } + + # 添加可选字段 + if data is not None: + result["data"] = data + if rows is not None: + result["rows"] = rows + if dict_content is not None: + result.update(dict_content) + if model_content is not None: + result.update(model_content.model_dump(by_alias=True)) + + return result + + @classmethod + def success( + cls, + msg: str = "操作成功", + data: Optional[Any] = None, + rows: Optional[Any] = None, + dict_content: Optional[Dict] = None, + model_content: Optional[BaseModel] = None, + ) -> JSONResponse: + """ + 成功响应方法。 + + :param msg: 响应消息,默认为 "操作成功" + :param data: 响应数据 + :param rows: 响应行数据(通常用于分页) + :param dict_content: 自定义字典内容 + :param model_content: 自定义 Pydantic 模型内容 + :return: JSONResponse 对象 + """ + result = cls._build_response( + code=HttpStatusConstant.SUCCESS, + msg=msg, + data=data, + rows=rows, + dict_content=dict_content, + model_content=model_content, + success=True, + ) + return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder(result)) + + @classmethod + def failure( + cls, + msg: str = "操作失败", + data: Optional[Any] = None, + rows: Optional[Any] = None, + dict_content: Optional[Dict] = None, + model_content: Optional[BaseModel] = None, + ) -> JSONResponse: + """ + 失败响应方法。 + + :param msg: 响应消息,默认为 "操作失败" + :param data: 响应数据 + :param rows: 响应行数据(通常用于分页) + :param dict_content: 自定义字典内容 + :param model_content: 自定义 Pydantic 模型内容 + :return: JSONResponse 对象 + """ + result = cls._build_response( + code=400, + msg=msg, + data=data, + rows=rows, + dict_content=dict_content, + model_content=model_content, + success=False, + ) + return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder(result)) + + @classmethod + def unauthorized( + cls, + msg: str = "登录信息已过期,访问系统资源失败", + data: Optional[Any] = None, + rows: Optional[Any] = None, + dict_content: Optional[Dict] = None, + model_content: Optional[BaseModel] = None, + ) -> JSONResponse: + """ + 未认证响应方法。 + + :param msg: 响应消息,默认为 "登录信息已过期,访问系统资源失败" + :param data: 响应数据 + :param rows: 响应行数据(通常用于分页) + :param dict_content: 自定义字典内容 + :param model_content: 自定义 Pydantic 模型内容 + :return: JSONResponse 对象 + """ + result = cls._build_response( + code=HttpStatusConstant.UNAUTHORIZED, + msg=msg, + data=data, + rows=rows, + dict_content=dict_content, + model_content=model_content, + success=False, + ) + return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder(result)) + + @classmethod + def forbidden( + cls, + msg: str = "该用户无此接口权限", + data: Optional[Any] = None, + rows: Optional[Any] = None, + dict_content: Optional[Dict] = None, + model_content: Optional[BaseModel] = None, + ) -> JSONResponse: + """ + 未授权响应方法。 + + :param msg: 响应消息,默认为 "该用户无此接口权限" + :param data: 响应数据 + :param rows: 响应行数据(通常用于分页) + :param dict_content: 自定义字典内容 + :param model_content: 自定义 Pydantic 模型内容 + :return: JSONResponse 对象 + """ + result = cls._build_response( + code=HttpStatusConstant.FORBIDDEN, + msg=msg, + data=data, + rows=rows, + dict_content=dict_content, + model_content=model_content, + success=False, + ) + return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder(result)) + + @classmethod + def error( + cls, + msg: str = "接口异常", + data: Optional[Any] = None, + rows: Optional[Any] = None, + dict_content: Optional[Dict] = None, + model_content: Optional[BaseModel] = None, + ) -> JSONResponse: + """ + 错误响应方法。 + + :param msg: 响应消息,默认为 "接口异常" + :param data: 响应数据 + :param rows: 响应行数据(通常用于分页) + :param dict_content: 自定义字典内容 + :param model_content: 自定义 Pydantic 模型内容 + :return: JSONResponse 对象 + """ + result = cls._build_response( + code=HttpStatusConstant.ERROR, + msg=msg, + data=data, + rows=rows, + dict_content=dict_content, + model_content=model_content, + success=False, + ) + return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder(result)) + + @classmethod + def streaming(cls, data: Any) -> StreamingResponse: + """ + 流式响应方法。 + + :param data: 流式传输的内容 + :return: StreamingResponse 对象 + """ + return StreamingResponse(content=data, status_code=status.HTTP_200_OK) diff --git a/utils/string.py b/utils/string.py new file mode 100644 index 0000000..c076c58 --- /dev/null +++ b/utils/string.py @@ -0,0 +1,109 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 23:58 +# @UpdateTime : 2025/01/18 23:58 +# @Author : sonder +# @File : string.py +# @Software : PyCharm +# @Comment : 本程序 +from typing import List + +from config.constant import CommonConstant + + +class String: + """ + 字符串工具类 + """ + + @classmethod + def is_blank(cls, string: str) -> bool: + """ + 校验字符串是否为''或全空格 + + :param string: 需要校验的字符串 + :return: 校验结果 + """ + if string is None: + return False + str_len = len(string) + if str_len == 0: + return True + else: + for i in range(str_len): + if string[i] != ' ': + return False + return True + + @classmethod + def is_empty(cls, string) -> bool: + """ + 校验字符串是否为''或None + + :param string: 需要校验的字符串 + :return: 校验结果 + """ + return string is None or len(string) == 0 + + @classmethod + def is_http(cls, link: str): + """ + 判断是否为http(s)://开头 + + :param link: 链接 + :return: 是否为http(s)://开头 + """ + return link.startswith(CommonConstant.HTTP) or link.startswith(CommonConstant.HTTPS) + + @classmethod + def contains_ignore_case(cls, search_str: str, compare_str: str): + """ + 查找指定字符串是否包含指定字符串同时串忽略大小写 + + :param search_str: 查找的字符串 + :param compare_str: 比对的字符串 + :return: 查找结果 + """ + if compare_str and search_str: + return compare_str.lower() in search_str.lower() + return False + + @classmethod + def contains_any_ignore_case(cls, search_str: str, compare_str_list: List[str]): + """ + 查找指定字符串是否包含指定字符串列表中的任意一个字符串同时串忽略大小写 + + :param search_str: 查找的字符串 + :param compare_str_list: 比对的字符串列表 + :return: 查找结果 + """ + if search_str and compare_str_list: + for compare_str in compare_str_list: + return cls.contains_ignore_case(search_str, compare_str) + return False + + @classmethod + def startswith_case(cls, search_str: str, compare_str: str): + """ + 查找指定字符串是否以指定字符串开头 + + :param search_str: 查找的字符串 + :param compare_str: 比对的字符串 + :return: 查找结果 + """ + if compare_str and search_str: + return search_str.startswith(compare_str) + return False + + @classmethod + def startswith_any_case(cls, search_str: str, compare_str_list: List[str]): + """ + 查找指定字符串是否以指定字符串列表中的任意一个字符串开头 + + :param search_str: 查找的字符串 + :param compare_str_list: 比对的字符串列表 + :return: 查找结果 + """ + if search_str and compare_str_list: + for compare_str in compare_str_list: + return cls.startswith_case(search_str, compare_str) + return False diff --git a/utils/upload.py b/utils/upload.py new file mode 100644 index 0000000..ceebf42 --- /dev/null +++ b/utils/upload.py @@ -0,0 +1,114 @@ +# _*_ coding : UTF-8 _*_ +# @Time : 2025/01/18 02:37 +# @UpdateTime : 2025/01/18 02:37 +# @Author : sonder +# @File : upload.py +# @Software : PyCharm +# @Comment : 本程序 +import os +import random +from datetime import datetime + +from fastapi import UploadFile + +from config.env import UploadConfig + + +class Upload: + """ + 上传工具类 + """ + + @classmethod + def generate_random_number(cls): + """ + 生成3位数字构成的字符串 + + :return: 3位数字构成的字符串 + """ + random_number = random.randint(1, 999) + + return f'{random_number:03}' + + @classmethod + def check_file_exists(cls, filepath: str): + """ + 检查文件是否存在 + + :param filepath: 文件路径 + :return: 校验结果 + """ + return os.path.exists(filepath) + + @classmethod + def check_file_extension(cls, file: UploadFile): + """ + 检查文件后缀是否合法 + + :param file: 文件对象 + :return: 校验结果 + """ + file_extension = file.filename.rsplit('.', 1)[-1] + if file_extension in UploadConfig.DEFAULT_ALLOWED_EXTENSION: + return True + return False + + @classmethod + def check_file_timestamp(cls, filename: str): + """ + 校验文件时间戳是否合法 + + :param filename: 文件名称 + :return: 校验结果 + """ + timestamp = filename.rsplit('.', 1)[0].split('_')[-1].split(UploadConfig.UPLOAD_MACHINE)[0] + try: + datetime.strptime(timestamp, '%Y%m%d%H%M%S') + return True + except ValueError: + return False + + @classmethod + def check_file_machine(cls, filename: str): + """ + 校验文件机器码是否合法 + + :param filename: 文件名称 + :return: 校验结果 + """ + if filename.rsplit('.', 1)[0][-4] == UploadConfig.UPLOAD_MACHINE: + return True + return False + + @classmethod + def check_file_random_code(cls, filename: str): + """ + 校验文件随机码是否合法 + + :param filename: 文件名称 + :return: 校验结果 + """ + valid_code_list = [f'{i:03}' for i in range(1, 999)] + if filename.rsplit('.', 1)[0][-3:] in valid_code_list: + return True + return False + + @classmethod + def generate_file(cls, filepath: str): + """ + 根据文件生成二进制数据 + + :param filepath: 文件路径 + :yield: 二进制数据 + """ + with open(filepath, 'rb') as response_file: + yield from response_file + + @classmethod + def delete_file(cls, filepath: str): + """ + 根据文件路径删除对应文件 + + :param filepath: 文件路径 + """ + os.remove(filepath) diff --git a/uvicorn_config.json b/uvicorn_config.json new file mode 100644 index 0000000..8e2b10d --- /dev/null +++ b/uvicorn_config.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "disable_existing_loggers": false, + "formatters": { + "default": { + "()": "uvicorn.logging.DefaultFormatter", + "fmt": "%(levelprefix)s %(message)s", + "use_colors": true, + "datefmt": "%Y-%m-%d %H:%M:%S" + }, + "access": { + "()": "uvicorn.logging.AccessFormatter", + "fmt": "%(asctime)s - %(levelprefix)s %(client_addr)s - \"%(request_line)s\" %(status_code)s", + "use_colors": true, + "datefmt": "%Y-%m-%d %H:%M:%S" + } + }, + "handlers": { + "default": { + "formatter": "default", + "class": "logging.StreamHandler", + "stream": "ext://sys.stderr" + }, + "access": { + "formatter": "access", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout" + } + }, + "loggers": { + "uvicorn": { + "handlers": [ + "default" + ], + "level": "INFO" + }, + "uvicorn.error": { + "level": "INFO" + }, + "uvicorn.access": { + "handlers": [ + "access" + ], + "level": "INFO", + "propagate": false + } + } +} \ No newline at end of file