refactor: P2 core+common 建设(应用工厂/扩展/工具层迁入 src/)
【结构】 - src/core/:database/response/jwt/password/security/redis_client/mail/scheduler - src/common/:base_model/base_db/forms(base_form+fields)/constants/utils - src/models_registry.py:模型聚合器(Flask-Migrate 全量表识别) 【绞杀者 shim】extensions/、utils/、application/constants|forms|models 打兼容转发 【兼容修复】应用工厂抽取至 application/factory.py(P2 前置,P1 已完成) 【基线修复】application/models(25) + middleware(7) 原被嵌套 .gitignore(*) 误排除出版本库,已补入(base_model/base_db 现为 shim,原版在 src/common/) 【登记既有缺陷】 1. flask db CLI 因 .env FLASK_VERSION 与 --version 冲突报错 → Makefile 已加 FLASK_SKIP_DOTENV=1 2. 本机 Redis 未设密码但 REDIS_AUTH=True,/captcha 报 AUTH 错误(环境问题) 【验收】22 模型表全量识别;src/ flake8 零告警;pytest 5 passed;应用启动; /login 表单校验、/user/page 鉴权拦截冒烟正常 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
92130ef070
commit
658ee6d596
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
src 包 —— DjangoAdmin 后端源码根包(重构目标结构)
|
||||
=============================================================
|
||||
|
||||
结构:
|
||||
core/ 基础设施层(不依赖业务)
|
||||
common/ 共享层(基类、常量、工具)
|
||||
modules/ 业务模块层(P3 阶段建设)
|
||||
routes/ 路由注册中心
|
||||
"""
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
common 共享层
|
||||
=============================================================
|
||||
|
||||
提供业务模块共享的基类与工具:
|
||||
- base_model.py / base_db.py 模型基类
|
||||
- forms/ 表单基类
|
||||
- constants/ 常量
|
||||
- utils/ 工具函数
|
||||
|
||||
依赖约束:common 层可依赖 core 层,不得依赖 modules 层。
|
||||
"""
|
||||
@@ -0,0 +1,208 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 一米阳光团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称"本产品")拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
"""
|
||||
数据库操作基类模块
|
||||
=============================================================
|
||||
|
||||
提供数据库模型的通用操作方法,包括:
|
||||
- 单条数据保存
|
||||
- 多条数据批量保存
|
||||
- 数据删除
|
||||
|
||||
企业级特性:
|
||||
1. 完整的异常处理和日志记录
|
||||
2. 事务边界由调用方控制(配合装饰器使用)
|
||||
3. 返回 self 支持链式调用
|
||||
4. 保留旧方法兼容性
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Any
|
||||
|
||||
from src.core.database import db
|
||||
|
||||
# 获取模块级日志记录器
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 数据库操作基类
|
||||
# ============================================================
|
||||
|
||||
class BaseDB:
|
||||
"""
|
||||
数据库操作基类
|
||||
|
||||
提供数据库的增删操作封装,所有业务模型应继承此类。
|
||||
|
||||
企业级设计原则:
|
||||
1. 默认不自动提交事务,由调用方通过装饰器统一管理事务
|
||||
2. 完整的异常处理和日志记录
|
||||
3. 返回 self 支持链式调用
|
||||
|
||||
方法列表:
|
||||
save(): 保存当前实例到数据库
|
||||
save_all(*args): 批量保存多个实例
|
||||
delete(): 从数据库删除当前实例
|
||||
"""
|
||||
|
||||
# ============================================================
|
||||
# 模型配置
|
||||
# ============================================================
|
||||
|
||||
# 定义为抽象类,不会在数据库中创建独立表
|
||||
__abstract__ = True
|
||||
|
||||
# ============================================================
|
||||
# 数据保存方法
|
||||
# ============================================================
|
||||
|
||||
def save(self) -> 'BaseDB':
|
||||
"""
|
||||
保存当前实例到数据库
|
||||
|
||||
将当前模型对象添加到数据库会话并刷新,但不提交事务。
|
||||
事务由上层装饰器统一管理,确保多个操作在同一事务中执行。
|
||||
|
||||
执行流程:
|
||||
1. 将对象添加到 SQLAlchemy 会话
|
||||
2. 刷新会话,发送 SQL 到数据库
|
||||
3. 返回自身支持链式调用
|
||||
|
||||
Returns:
|
||||
BaseDB: 当前实例(支持链式调用)
|
||||
|
||||
Raises:
|
||||
Exception: 数据库操作异常会向上抛出,由装饰器统一处理
|
||||
|
||||
Example:
|
||||
@transactional_response
|
||||
def add_position():
|
||||
position = Position(name='经理')
|
||||
position.save()
|
||||
return R.ok(msg="添加成功")
|
||||
"""
|
||||
try:
|
||||
db.session.add(self)
|
||||
db.session.flush()
|
||||
logger.debug(f"save 添加到会话, 模型: {self.__class__.__name__}")
|
||||
return self
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"save 异常, 模型: {self.__class__.__name__}, 错误: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def save_all(*args) -> List[Any]:
|
||||
"""
|
||||
批量保存多个实例到数据库
|
||||
|
||||
将多个模型对象批量添加到数据库会话并刷新,但不提交事务。
|
||||
事务由上层装饰器统一管理。
|
||||
|
||||
Args:
|
||||
*args: 可变参数,传入多个模型实例对象
|
||||
|
||||
Returns:
|
||||
List[Any]: 保存的实例列表
|
||||
|
||||
Raises:
|
||||
Exception: 数据库操作异常会向上抛出
|
||||
|
||||
Example:
|
||||
@transactional_response
|
||||
def batch_add():
|
||||
BaseDB.save_all(user1, user2)
|
||||
return R.ok(msg="批量添加成功")
|
||||
"""
|
||||
if not args:
|
||||
logger.warning("save_all 收到空参数列表")
|
||||
return []
|
||||
|
||||
try:
|
||||
db.session.add_all(args)
|
||||
db.session.flush()
|
||||
logger.debug(f"save_all 添加到会话, 数量: {len(args)}")
|
||||
return list(args)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"save_all 异常, 数量: {len(args)}, 错误: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
# ============================================================
|
||||
# 数据删除方法
|
||||
# ============================================================
|
||||
|
||||
def delete(self) -> bool:
|
||||
"""
|
||||
从数据库删除当前实例
|
||||
|
||||
执行物理删除操作,数据将从数据库中永久移除。
|
||||
|
||||
注意:
|
||||
此方法执行物理删除,数据不可恢复。
|
||||
如需软删除,请使用 BaseModel.delete_by_id()。
|
||||
|
||||
Returns:
|
||||
bool: 删除成功返回 True
|
||||
|
||||
Raises:
|
||||
Exception: 数据库操作异常会向上抛出
|
||||
"""
|
||||
try:
|
||||
db.session.delete(self)
|
||||
db.session.flush()
|
||||
logger.debug(f"delete 标记删除, 模型: {self.__class__.__name__}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"delete 异常, 模型: {self.__class__.__name__}, 错误: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,671 @@
|
||||
"""
|
||||
基类模型模块
|
||||
=============================================================
|
||||
|
||||
提供所有数据库模型的基类,包含通用字段和公共方法。
|
||||
所有业务模型应继承此类,避免重复定义通用字段。
|
||||
|
||||
通用字段包括:
|
||||
- id: 主键ID
|
||||
- create_user: 创建人
|
||||
- create_time: 创建时间
|
||||
- update_user: 更新人
|
||||
- update_time: 更新时间
|
||||
- is_delete: 软删除标识
|
||||
|
||||
企业级特性:
|
||||
1. 参数校验(id 有效性检查)
|
||||
2. 完整的异常处理和日志记录
|
||||
3. 增强版分页查询(支持等值、模糊、范围查询)
|
||||
4. 事务管理由调用方控制(配合装饰器使用)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List, Dict, Any, Tuple, Union
|
||||
|
||||
from src.core.database import db
|
||||
from src.common.utils.case_converter import convert_camel_to_snake, convert_camel_case
|
||||
from src.common.utils.model_utils import models_to_list
|
||||
from src.common.utils.request import like_contains, parse_id_list
|
||||
from src.common.constants.common import SERIALIZE_EXCLUDE_FIELDS
|
||||
|
||||
# 获取模块级日志记录器
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 基类模型
|
||||
# ============================================================
|
||||
|
||||
|
||||
class BaseModel(db.Model):
|
||||
"""
|
||||
数据库模型基类
|
||||
|
||||
所有业务模型应继承此类,自动获得通用字段和公共方法。
|
||||
|
||||
提供的功能:
|
||||
1. 通用字段:id、创建人、创建时间、更新人、更新时间、软删除标识
|
||||
2. 序列化方法:to_dict()、to_json()
|
||||
3. 查询方法:get_by_id()、get_by_ids()、get_all_active()
|
||||
4. 分页查询:paginate() 支持多种筛选条件
|
||||
5. 删除方法:delete_by_id()、batch_delete()、batch_delete_r()、batch_restore()
|
||||
6. 辅助方法:exists()、get_delete_status_text()
|
||||
"""
|
||||
|
||||
# ============================================================
|
||||
# 模型配置
|
||||
# ============================================================
|
||||
|
||||
# 定义为抽象类,不会在数据库中创建独立表
|
||||
__abstract__ = True
|
||||
|
||||
# ============================================================
|
||||
# 通用字段定义
|
||||
# ============================================================
|
||||
|
||||
# 主键ID:自增长整数
|
||||
id = db.Column(
|
||||
db.Integer,
|
||||
primary_key=True,
|
||||
autoincrement=True,
|
||||
comment="主键ID"
|
||||
)
|
||||
|
||||
# 创建人:记录创建者,由业务层通过 get_username() 设置
|
||||
create_user = db.Column(
|
||||
db.String(64),
|
||||
default='',
|
||||
comment="创建人"
|
||||
)
|
||||
|
||||
# 创建时间:记录创建时间,创建后不再变化(UTC时区)
|
||||
create_time = db.Column(
|
||||
db.DATETIME,
|
||||
default=lambda: datetime.now(tz=timezone.utc),
|
||||
comment="创建时间"
|
||||
)
|
||||
|
||||
# 更新人:记录最后修改者,由业务层通过 get_username() 设置
|
||||
update_user = db.Column(
|
||||
db.String(64),
|
||||
default='',
|
||||
comment="更新人"
|
||||
)
|
||||
|
||||
# 更新时间:记录最后修改时间,更新时自动刷新(UTC时区)
|
||||
# 注意:onupdate 仅在 ORM 实例脏检测刷新时生效,Query.update() 不会触发
|
||||
update_time = db.Column(
|
||||
db.DATETIME,
|
||||
default=lambda: datetime.now(tz=timezone.utc),
|
||||
onupdate=lambda: datetime.now(tz=timezone.utc),
|
||||
comment="更新时间"
|
||||
)
|
||||
|
||||
# 软删除标识:0-正常,1-已删除
|
||||
# 使用软删除而非物理删除,便于数据恢复和审计
|
||||
is_delete = db.Column(
|
||||
db.Integer,
|
||||
default=0,
|
||||
comment="删除标识:0-正常 1-已删除"
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# 初始化方法
|
||||
# ============================================================
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
通用初始化方法
|
||||
|
||||
支持驼峰命名转下划线,自动过滤无效字段。
|
||||
|
||||
处理流程:
|
||||
1. 驼峰转下划线:前端使用驼峰命名,数据库使用下划线命名
|
||||
2. 过滤 None 值:保持数据库默认值生效
|
||||
3. 字段校验:只保留模型中存在的字段,防止注入
|
||||
|
||||
Args:
|
||||
**kwargs: 任意关键字参数
|
||||
|
||||
Example:
|
||||
# 前端传入驼峰命名
|
||||
position = Position(createUser='admin', name='经理')
|
||||
# 自动转换为:create_user='admin', name='经理'
|
||||
"""
|
||||
# 将驼峰命名的参数转换为下划线命名
|
||||
kwargs = convert_camel_to_snake(kwargs)
|
||||
|
||||
# 过滤掉值为 None 的参数
|
||||
kwargs = {k: v for k, v in kwargs.items() if v is not None}
|
||||
|
||||
# 只保留模型中存在的字段
|
||||
valid_fields = {c.name for c in self.__table__.columns}
|
||||
kwargs = {k: v for k, v in kwargs.items() if k in valid_fields}
|
||||
|
||||
# 调用父类初始化
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# ============================================================
|
||||
# 序列化方法
|
||||
# ============================================================
|
||||
|
||||
def to_dict(self, convert_case=True) -> Dict[str, Any]:
|
||||
"""
|
||||
将模型实例转换为字典
|
||||
|
||||
遍历模型的所有列,将字段名和值组装成字典。
|
||||
自动排除 is_delete、password、salt 等敏感字段。
|
||||
特别处理 datetime 类型,格式化为字符串便于 JSON 序列化。
|
||||
|
||||
Args:
|
||||
convert_case: 是否将字段名转换为驼峰命名,默认False(保持下划线)
|
||||
|
||||
Returns:
|
||||
dict: 包含所有字段的字典(不含敏感字段)
|
||||
|
||||
Example:
|
||||
position = Position.query.get(1)
|
||||
data = position.to_dict()
|
||||
# 返回:{'id': 1, 'name': '经理', 'create_time': '2024-01-01 12:00:00'}
|
||||
"""
|
||||
result = {}
|
||||
for column in self.__table__.columns:
|
||||
# 跳过敏感字段
|
||||
if column.name in SERIALIZE_EXCLUDE_FIELDS:
|
||||
continue
|
||||
field_name = column.name
|
||||
value = getattr(self, field_name)
|
||||
# 日期时间类型格式化为字符串
|
||||
if isinstance(value, datetime):
|
||||
value = value.strftime('%Y-%m-%d %H:%M:%S')
|
||||
if convert_case:
|
||||
field_name = convert_camel_case(field_name)
|
||||
result[field_name] = value
|
||||
return result
|
||||
|
||||
# ============================================================
|
||||
# 通用查询方法(带参数校验和异常处理)
|
||||
# ============================================================
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, id: Optional[int]) -> Optional['BaseModel']:
|
||||
"""
|
||||
根据ID获取未删除的记录
|
||||
|
||||
企业级实现:
|
||||
1. 参数校验:检查 id 是否有效
|
||||
2. 异常处理:捕获数据库异常并记录日志
|
||||
|
||||
Args:
|
||||
id: 主键ID
|
||||
|
||||
Returns:
|
||||
Optional[BaseModel]: 记录对象,不存在时返回 None
|
||||
|
||||
Example:
|
||||
position = Position.get_by_id(1)
|
||||
if position:
|
||||
print(position.name)
|
||||
"""
|
||||
# 参数有效性校验
|
||||
if id is None or id <= 0:
|
||||
logger.warning(f"get_by_id 收到无效ID: {id}")
|
||||
return None
|
||||
|
||||
try:
|
||||
return cls.query.filter(cls.id == id, cls.is_delete == 0).first()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"get_by_id 查询异常, 模型: {cls.__name__}, ID: {id}, 错误: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_by_ids(cls, ids: Optional[List[int]]) -> List['BaseModel']:
|
||||
"""
|
||||
根据ID列表批量获取未删除的记录
|
||||
|
||||
适用于批量查询场景,如根据ID列表批量查询用户信息。
|
||||
自动过滤无效ID和已删除记录。
|
||||
|
||||
Args:
|
||||
ids: 主键ID列表
|
||||
|
||||
Returns:
|
||||
List[BaseModel]: 记录对象列表,无效参数返回空列表
|
||||
|
||||
Example:
|
||||
positions = Position.get_by_ids([1, 2, 3])
|
||||
for pos in positions:
|
||||
print(pos.name)
|
||||
"""
|
||||
# 空列表直接返回
|
||||
if not ids:
|
||||
logger.debug(f"get_by_ids 收到空ID列表, 模型: {cls.__name__}")
|
||||
return []
|
||||
|
||||
# 过滤无效ID
|
||||
valid_ids = [id for id in ids if id and id > 0]
|
||||
if not valid_ids:
|
||||
logger.warning(f"get_by_ids 所有ID均无效: {ids}")
|
||||
return []
|
||||
|
||||
try:
|
||||
return cls.query.filter(cls.id.in_(valid_ids), cls.is_delete == 0).all()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"get_by_ids 查询异常, 模型: {cls.__name__}, 错误: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_all_active(cls) -> List['BaseModel']:
|
||||
"""
|
||||
获取所有未删除的记录
|
||||
|
||||
适用于需要获取全部有效数据的场景。
|
||||
数据量大时建议使用分页查询,避免内存溢出。
|
||||
|
||||
Returns:
|
||||
List[BaseModel]: 所有未删除的记录列表
|
||||
"""
|
||||
try:
|
||||
return cls.query.filter(cls.is_delete == 0).all()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"get_all_active 查询异常, 模型: {cls.__name__}, 错误: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
return []
|
||||
|
||||
# ============================================================
|
||||
# 增强版分页查询方法
|
||||
# ============================================================
|
||||
|
||||
@classmethod
|
||||
def paginate( # noqa: C901 (兼容性增强分页查询,复杂度较高属存量设计)
|
||||
cls,
|
||||
page: int = 1,
|
||||
per_page: int = 15,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
like_filters: Optional[Dict[str, str]] = None,
|
||||
range_filters: Optional[Dict[str, Tuple[Any, Any]]] = None,
|
||||
order_by: Optional[Union[str, List[str]]] = None,
|
||||
order_desc: Optional[Union[str, List[str]]] = None,
|
||||
with_deleted: bool = False,
|
||||
as_dict: bool = True
|
||||
) -> Tuple[List[Union[Dict, Any]], int, int]:
|
||||
"""
|
||||
通用分页查询方法(增强版)
|
||||
|
||||
企业级实现:
|
||||
1. 参数修正:page 和 per_page 自动修正为有效值
|
||||
2. 多种筛选:支持等值、模糊、范围三种筛选方式
|
||||
3. 灵活排序:支持单字段和多字段排序
|
||||
4. 自动转换:可选的字典转换
|
||||
|
||||
支持的筛选类型:
|
||||
1. 等值筛选(filters):精确匹配,如 status=1
|
||||
2. 模糊筛选(like_filters):LIKE 查询,如 name LIKE '%经理%'
|
||||
3. 范围筛选(range_filters):区间查询,如 create_time BETWEEN
|
||||
|
||||
Args:
|
||||
page: 当前页码,默认1(自动修正为 >= 1)
|
||||
per_page: 每页数量,默认15(自动修正为 1-100)
|
||||
filters: 等值筛选,如 {'status': 1}
|
||||
like_filters: 模糊筛选,如 {'name': '经理'}
|
||||
range_filters: 范围筛选,如 {'create_time': ('2024-01-01', '2024-12-31')}
|
||||
order_by: 升序排序字段,如 'sort' 或 ['sort', 'id']
|
||||
order_desc: 降序排序字段,如 'create_time'
|
||||
with_deleted: 是否包含已删除记录,默认 False
|
||||
as_dict: 是否转换为字典,默认 True
|
||||
|
||||
Returns:
|
||||
tuple: (items, total, pages)
|
||||
- items: 当前页数据列表
|
||||
- total: 总记录数
|
||||
- pages: 总页数
|
||||
|
||||
Example:
|
||||
# 基础分页
|
||||
items, total, pages = Position.paginate(
|
||||
page=1, per_page=10,
|
||||
filters={'status': 1},
|
||||
order_by='sort'
|
||||
)
|
||||
|
||||
# 组合查询
|
||||
items, total, pages = Position.paginate(
|
||||
page=1, per_page=10,
|
||||
filters={'status': 1},
|
||||
like_filters={'name': '经理'},
|
||||
order_by='sort',
|
||||
order_desc='create_time'
|
||||
)
|
||||
"""
|
||||
# ============================================================
|
||||
# 步骤1:参数修正
|
||||
# ============================================================
|
||||
if page < 1:
|
||||
page = 1
|
||||
if per_page < 1:
|
||||
per_page = 15
|
||||
if per_page > 100:
|
||||
per_page = 100
|
||||
|
||||
try:
|
||||
# ============================================================
|
||||
# 步骤2:构建基础查询
|
||||
# ============================================================
|
||||
query = cls.query
|
||||
if not with_deleted:
|
||||
query = query.filter(cls.is_delete == 0)
|
||||
|
||||
# ============================================================
|
||||
# 步骤3:应用等值筛选
|
||||
# ============================================================
|
||||
if filters:
|
||||
for key, value in filters.items():
|
||||
if value is not None and hasattr(cls, key):
|
||||
query = query.filter(getattr(cls, key) == value)
|
||||
|
||||
# ============================================================
|
||||
# 步骤4:应用模糊筛选
|
||||
# ============================================================
|
||||
if like_filters:
|
||||
for key, value in like_filters.items():
|
||||
if value is not None and hasattr(cls, key):
|
||||
query = query.filter(getattr(cls, key).like(like_contains(value), escape='\\'))
|
||||
|
||||
# ============================================================
|
||||
# 步骤5:应用范围筛选
|
||||
# ============================================================
|
||||
if range_filters:
|
||||
for key, (start, end) in range_filters.items():
|
||||
if hasattr(cls, key):
|
||||
if start is not None:
|
||||
query = query.filter(getattr(cls, key) >= start)
|
||||
if end is not None:
|
||||
query = query.filter(getattr(cls, key) <= end)
|
||||
|
||||
# ============================================================
|
||||
# 步骤6:应用排序
|
||||
# ============================================================
|
||||
if order_by:
|
||||
if isinstance(order_by, str):
|
||||
query = query.order_by(getattr(cls, order_by).asc())
|
||||
elif isinstance(order_by, list):
|
||||
for field in order_by:
|
||||
query = query.order_by(getattr(cls, field).asc())
|
||||
|
||||
if order_desc:
|
||||
if isinstance(order_desc, str):
|
||||
query = query.order_by(getattr(cls, order_desc).desc())
|
||||
elif isinstance(order_desc, list):
|
||||
for field in order_desc:
|
||||
query = query.order_by(getattr(cls, field).desc())
|
||||
|
||||
# ============================================================
|
||||
# 步骤7:计算总数和页数
|
||||
# ============================================================
|
||||
total = query.count()
|
||||
pages = (total + per_page - 1) // per_page if total > 0 else 0
|
||||
|
||||
# ============================================================
|
||||
# 步骤8:执行分页查询
|
||||
# ============================================================
|
||||
items = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
# ============================================================
|
||||
# 步骤9:转换为字典(可选)
|
||||
# ============================================================
|
||||
if as_dict:
|
||||
items = models_to_list(items)
|
||||
|
||||
return items, total, pages
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"paginate 查询异常, 模型: {cls.__name__}, 错误: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
return [], 0, 0
|
||||
|
||||
# ============================================================
|
||||
# 删除方法(事务由调用方控制)
|
||||
# ============================================================
|
||||
|
||||
@classmethod
|
||||
def delete_by_id(cls, id: Optional[int]) -> bool:
|
||||
"""
|
||||
删除单条记录(软删除)
|
||||
|
||||
将指定ID记录的 is_delete 标识设置为 1。
|
||||
事务由上层装饰器统一管理。
|
||||
|
||||
Args:
|
||||
id: 主键ID
|
||||
|
||||
Returns:
|
||||
bool: 删除成功返回 True,失败返回 False
|
||||
|
||||
Example:
|
||||
@transactional_response
|
||||
def delete_position():
|
||||
Position.delete_by_id(1)
|
||||
return R.ok(msg="删除成功")
|
||||
"""
|
||||
# 参数校验
|
||||
if id is None or id <= 0:
|
||||
logger.warning(f"delete_by_id 收到无效ID: {id}")
|
||||
return False
|
||||
|
||||
try:
|
||||
record = cls.get_by_id(id)
|
||||
if not record:
|
||||
logger.debug(f"delete_by_id 记录不存在, 模型: {cls.__name__}, ID: {id}")
|
||||
return False
|
||||
|
||||
# 标记为已删除
|
||||
record.is_delete = 1
|
||||
db.session.add(record)
|
||||
|
||||
logger.info(f"delete_by_id 成功, 模型: {cls.__name__}, ID: {id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"delete_by_id 异常, 模型: {cls.__name__}, ID: {id}, 错误: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def batch_delete(cls, ids: Optional[List[int]]) -> int:
|
||||
"""
|
||||
批量删除(软删除)
|
||||
|
||||
一次性删除多条记录,使用 IN 查询提高效率。
|
||||
只删除未删除的记录。
|
||||
|
||||
Args:
|
||||
ids: 主键ID列表
|
||||
|
||||
Returns:
|
||||
int: 实际删除的记录数
|
||||
|
||||
Example:
|
||||
@transactional_response
|
||||
def batch_delete():
|
||||
count = Position.batch_delete([1, 2, 3])
|
||||
return R.ok(msg=f"删除 {count} 条记录")
|
||||
"""
|
||||
# 空列表直接返回
|
||||
if not ids:
|
||||
return 0
|
||||
|
||||
# 过滤无效ID
|
||||
valid_ids = [id for id in ids if id and id > 0]
|
||||
if not valid_ids:
|
||||
logger.warning(f"batch_delete 所有ID均无效: {ids}")
|
||||
return 0
|
||||
|
||||
try:
|
||||
# 批量更新,synchronize_session=False 提高性能
|
||||
count = cls.query.filter(
|
||||
cls.id.in_(valid_ids),
|
||||
cls.is_delete == 0
|
||||
).update(
|
||||
{cls.is_delete: 1, cls.update_time: datetime.now(tz=timezone.utc)},
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"batch_delete 成功, 模型: {cls.__name__}, 删除数量: {count}"
|
||||
)
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"batch_delete 异常, 模型: {cls.__name__}, 错误: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def batch_delete_r(cls, ids_str: Optional[str]):
|
||||
"""
|
||||
批量删除并返回R对象(供service层直接使用)
|
||||
|
||||
封装了ID校验、解析、批量删除、返回R响应的完整流程,
|
||||
消除各service中重复的删除逻辑。
|
||||
|
||||
Args:
|
||||
ids_str: 逗号分隔的ID字符串,如 "1,2,3"
|
||||
|
||||
Returns:
|
||||
R: 操作结果
|
||||
|
||||
Example:
|
||||
def delete_positions(position_ids):
|
||||
return Position.batch_delete_r(position_ids)
|
||||
"""
|
||||
if not ids_str:
|
||||
from utils import R
|
||||
return R.failed("记录ID不存在")
|
||||
|
||||
id_list = parse_id_list(ids_str)
|
||||
if not id_list:
|
||||
from utils import R
|
||||
return R.failed("无效的记录ID")
|
||||
|
||||
count = cls.batch_delete(id_list)
|
||||
from utils import R
|
||||
return R.ok(msg=f"本次共删除{count}条数据")
|
||||
|
||||
@classmethod
|
||||
def batch_restore(cls, ids: Optional[List[int]]) -> int:
|
||||
"""
|
||||
批量恢复已删除的记录
|
||||
|
||||
将已删除的记录恢复为正常状态(is_delete = 0)。
|
||||
只恢复已删除的记录。
|
||||
|
||||
Args:
|
||||
ids: 主键ID列表
|
||||
|
||||
Returns:
|
||||
int: 实际恢复的记录数
|
||||
|
||||
Example:
|
||||
@transactional_response
|
||||
def batch_restore():
|
||||
count = Position.batch_restore([1, 2, 3])
|
||||
return R.ok(msg=f"恢复 {count} 条记录")
|
||||
"""
|
||||
# 空列表直接返回
|
||||
if not ids:
|
||||
return 0
|
||||
|
||||
# 过滤无效ID
|
||||
valid_ids = [id for id in ids if id and id > 0]
|
||||
if not valid_ids:
|
||||
logger.warning(f"batch_restore 所有ID均无效: {ids}")
|
||||
return 0
|
||||
|
||||
try:
|
||||
# 批量恢复,只恢复已删除的记录
|
||||
count = cls.query.filter(
|
||||
cls.id.in_(valid_ids),
|
||||
cls.is_delete == 1
|
||||
).update(
|
||||
{cls.is_delete: 0},
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"batch_restore 成功, 模型: {cls.__name__}, 恢复数量: {count}"
|
||||
)
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"batch_restore 异常, 模型: {cls.__name__}, 错误: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
# ============================================================
|
||||
# 辅助方法
|
||||
# ============================================================
|
||||
|
||||
@classmethod
|
||||
def get_delete_status_text(cls, is_delete: int) -> str:
|
||||
"""
|
||||
获取删除状态文本
|
||||
|
||||
将数字状态的 is_delete 转换为可读的文本。
|
||||
用于前端展示或日志记录。
|
||||
|
||||
Args:
|
||||
is_delete: 删除标识值(0或1)
|
||||
|
||||
Returns:
|
||||
str: 状态文本
|
||||
"""
|
||||
return {0: '正常', 1: '已删除'}.get(is_delete, '未知')
|
||||
|
||||
@classmethod
|
||||
def exists(cls, id: Optional[int]) -> bool:
|
||||
"""
|
||||
检查记录是否存在(未删除)
|
||||
|
||||
快速检查记录是否存在,返回布尔值。
|
||||
适用于记录存在性验证场景。
|
||||
|
||||
Args:
|
||||
id: 主键ID
|
||||
|
||||
Returns:
|
||||
bool: 存在返回 True,否则返回 False
|
||||
|
||||
Example:
|
||||
if Position.exists(1):
|
||||
print("记录存在")
|
||||
"""
|
||||
# 参数校验
|
||||
if id is None or id <= 0:
|
||||
return False
|
||||
|
||||
try:
|
||||
return cls.query.filter(cls.id == id, cls.is_delete == 0).count() > 0
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"exists 查询异常, 模型: {cls.__name__}, ID: {id}, 错误: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
return False
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
constants 常量包
|
||||
=============================================================
|
||||
|
||||
- common.py 通用常量(分页、默认密码、消息文本、序列化排除字段)
|
||||
- dictionary_constants.py 字典相关常量(原 application/constants/dict.py)
|
||||
- error_code.py 错误码定义(后续阶段建设)
|
||||
"""
|
||||
@@ -0,0 +1,85 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 珊瑚云团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 珊瑚云团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
# ======================= 分页配置 ==========================
|
||||
# 该部分配置用于控制列表页面的数据分页显示
|
||||
# 通过调整每页记录数可以优化页面加载速度和用户体验
|
||||
# ==========================================================
|
||||
|
||||
# 默认每页显示记录数
|
||||
# 类型:int
|
||||
# 默认值:10
|
||||
# 说明:列表页面默认每页显示的数据条数,可根据实际需求调整
|
||||
PAGE_SIZE = 10
|
||||
|
||||
# ======================= 用户配置 ==========================
|
||||
# 该部分配置用于管理用户账户相关的默认设置
|
||||
# 包含新用户创建和密码重置时的默认值
|
||||
# ==========================================================
|
||||
|
||||
# 默认密码
|
||||
# 类型:str
|
||||
# 默认值:"123456"
|
||||
# 说明:新创建用户或重置密码时的默认密码,建议首次登录后强制修改
|
||||
DEFAULT_PASSWORD = "123456"
|
||||
|
||||
# ======================= 消息提示配置 ==========================
|
||||
# 该部分配置定义了所有操作响应的统一消息文本
|
||||
# 用于保持系统前后端消息提示的一致性
|
||||
# ==========================================================
|
||||
|
||||
# 操作成功提示信息
|
||||
# 类型:str
|
||||
# 默认值:"操作成功"
|
||||
# 说明:统一的操作成功返回消息,所有成功操作均使用此消息
|
||||
MESSAGE_OK = "操作成功"
|
||||
|
||||
# 操作失败提示信息
|
||||
# 类型:str
|
||||
# 默认值:"操作失败"
|
||||
# 说明:统一的操作失败返回消息,所有失败操作均使用此消息
|
||||
MESSAGE_FAIL = "操作失败"
|
||||
|
||||
# ======================= 序列化安全配置 ==========================
|
||||
# 模型序列化时统一排除的敏感字段,防止密码、盐值等泄露给前端
|
||||
# ==================================================================
|
||||
|
||||
SERIALIZE_EXCLUDE_FIELDS = frozenset({'is_delete', 'password', 'salt'})
|
||||
@@ -0,0 +1,195 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 珊瑚云团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 珊瑚云团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
"""
|
||||
系统常量定义模块
|
||||
=============================================================
|
||||
|
||||
该模块定义了系统中使用的各种常量字典,主要用于:
|
||||
1. 将数据库中的数字/代码值转换为可读的文本标签
|
||||
2. 为前端下拉框、单选框、复选框等提供选项数据
|
||||
3. 统一管理系统中所有枚举值的映射关系
|
||||
|
||||
所有常量均为字典类型,键为存储值,值为显示文本。
|
||||
"""
|
||||
|
||||
# ==================== 基础信息常量 ====================
|
||||
|
||||
# 性别映射字典
|
||||
# 用于用户信息中的性别字段显示
|
||||
GENDER_LIST = {
|
||||
1: "男", # 男性
|
||||
2: "女", # 女性
|
||||
3: "保密", # 保密/未公开
|
||||
}
|
||||
|
||||
# ==================== 通用状态常量 ====================
|
||||
|
||||
# 状态映射字典
|
||||
# 用于系统中各类数据的状态字段显示
|
||||
# 1-正常(启用、有效、激活等状态)
|
||||
# 2-禁用(停用、无效、冻结等状态)
|
||||
STATUS_LIST = {
|
||||
1: "正常", # 正常状态
|
||||
2: "禁用", # 禁用状态
|
||||
}
|
||||
|
||||
# ==================== 参数相关常量 ====================
|
||||
|
||||
# 参数类型映射字典
|
||||
# 用于系统参数管理中的参数类型字段显示
|
||||
# 0-系统参数(系统级配置,影响系统运行)
|
||||
# 1-业务参数(业务级配置,影响业务逻辑)
|
||||
PARAM_TYPE_LIST = {
|
||||
0: "系统参数", # 系统参数
|
||||
1: "业务参数", # 业务参数
|
||||
}
|
||||
|
||||
# ==================== 友情链接常量 ====================
|
||||
|
||||
# 友链类型映射字典
|
||||
# 用于区分友情链接的不同合作性质
|
||||
LINK_TYPE_LIST = {
|
||||
1: "友情链接", # 普通友情链接
|
||||
2: "合作伙伴" # 合作伙伴链接(关系更紧密)
|
||||
}
|
||||
|
||||
# 友链形式映射字典
|
||||
# 用于指定友情链接的展示形式
|
||||
LINK_FORM_LIST = {
|
||||
1: "文字链接", # 纯文本形式的链接
|
||||
2: "图片链接", # 带有Logo/图片的链接
|
||||
}
|
||||
|
||||
# ==================== 配置相关常量 ====================
|
||||
|
||||
# 配置项类型映射字典
|
||||
# 用于定义系统配置项的数据类型和对应的输入控件
|
||||
CONFIG_ITEM_TYPE_LIST = {
|
||||
"text": "单行文本", # 普通单行文本输入框
|
||||
"textarea": "多行文本", # 多行文本域
|
||||
"ueditor": "富文本编辑器", # 富文本编辑器(支持图文排版)
|
||||
"date": "日期", # 日期选择器
|
||||
"datetime": "时间", # 日期时间选择器
|
||||
"number": "数字", # 数字输入框
|
||||
"select": "下拉框", # 下拉选择框
|
||||
"radio": "单选框", # 单选按钮组
|
||||
"checkbox": "复选框", # 复选框组(多选)
|
||||
"image": "单张图片", # 单张图片上传
|
||||
"images": "多张图片", # 多张图片上传
|
||||
"password": "密码", # 密码输入框
|
||||
"icon": "字体图标", # 字体图标选择器
|
||||
"file": "单个文件", # 单个文件上传
|
||||
"files": "多个文件", # 多个文件上传
|
||||
"hidden": "隐藏", # 隐藏字段
|
||||
"readonly": "只读文本", # 只读文本显示
|
||||
}
|
||||
|
||||
# ==================== 地区相关常量 ====================
|
||||
|
||||
# 城市级别映射字典
|
||||
# 用于标识行政区划的级别
|
||||
CITY_LEVEL_LIST = {
|
||||
1: "省份", # 省级行政区(省、自治区、直辖市)
|
||||
2: "城市", # 地级行政区(市、自治州)
|
||||
3: "县区", # 县级行政区(区、县、县级市)
|
||||
4: "街道" # 乡级行政区(街道、镇、乡)
|
||||
}
|
||||
|
||||
# 通知类型:1-通知 2-公告
|
||||
NOTICE_TYPE_LIST = {
|
||||
1: "通知",
|
||||
2: "公告",
|
||||
}
|
||||
|
||||
# ==================== 操作日志相关常量 ====================
|
||||
|
||||
# 操作日志来源映射字典
|
||||
# 用于标识操作日志的请求来源
|
||||
OPERATION_LOG_SOURCE_LIST = {
|
||||
0: "后台系统", # 后台管理系统操作
|
||||
1: "手机站", # 手机端/移动端操作
|
||||
2: "客户端", # PC客户端/应用程序操作
|
||||
3: "其他", # 其他来源
|
||||
}
|
||||
|
||||
# 操作日志类型映射字典
|
||||
# 用于标识操作日志的具体操作类型
|
||||
OPERATION_LOG_TYPE_LIST = {
|
||||
1: "登录",
|
||||
2: "退出",
|
||||
3: "新增",
|
||||
4: "修改",
|
||||
5: "删除",
|
||||
6: "查询",
|
||||
7: "导出",
|
||||
8: "导入",
|
||||
9: "提交",
|
||||
10: "撤销",
|
||||
11: "审核",
|
||||
12: "强退",
|
||||
13: "开账号",
|
||||
14: "清空",
|
||||
15: "授权",
|
||||
16: "缓存",
|
||||
17: "生成代码",
|
||||
18: "其他",
|
||||
}
|
||||
|
||||
# ==================== 登录日志相关常量 ====================
|
||||
|
||||
# 登录日志类型映射字典
|
||||
# 用于标识登录日志的操作类型
|
||||
LOGIN_LOG_TYPE_LIST = {
|
||||
1: "登录",
|
||||
2: "退出",
|
||||
}
|
||||
|
||||
# ==================== 文章相关常量 ====================
|
||||
|
||||
# 文章状态映射字典
|
||||
# 用于文章管理中的状态字段显示
|
||||
# 0-正常(已发布、上架状态)
|
||||
# 1-下架(已下架、草稿状态)
|
||||
ARTICLE_STATUS_LIST = {
|
||||
0: "正常", # 正常/已发布
|
||||
1: "下架", # 下架/草稿
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
forms 表单基类包
|
||||
=============================================================
|
||||
|
||||
- base_form.py 表单基类(BaseForm,支持 JSON 数组)
|
||||
- fields.py 自定义字段(ArrayField)
|
||||
"""
|
||||
@@ -0,0 +1,105 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 一米阳光团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
import json
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
|
||||
from src.common.forms.fields import ArrayField
|
||||
|
||||
|
||||
class BaseForm(FlaskForm):
|
||||
"""
|
||||
自定义表单基类,增强对 JSON 数组的支持
|
||||
|
||||
重写 process 方法,确保 ArrayField 能正确处理数组数据。
|
||||
|
||||
使用方式:
|
||||
class UserForm(BaseForm):
|
||||
city = ArrayField('行政区划')
|
||||
roles = ArrayField('用户角色')
|
||||
"""
|
||||
|
||||
def process(self, formdata=None, obj=None, data=None, **kwargs):
|
||||
"""
|
||||
重写 process 方法,正确处理数组字段
|
||||
|
||||
问题的根源:Flask-WTF 的 Form.process 方法在处理 data 参数时,
|
||||
会调用 _process_multidict 方法,该方法会将列表值当作多值参数处理,
|
||||
导致数组被拆分成单个值。
|
||||
|
||||
解决方案:在调用父类方法之前,先处理 ArrayField 类型的数据,
|
||||
将列表值转换为 JSON 字符串,避免被拆分。
|
||||
|
||||
Args:
|
||||
formdata: 表单数据(通常是 request.form)
|
||||
obj: 模型对象
|
||||
data: 字典数据(通常是 request.get_json())
|
||||
**kwargs: 其他参数
|
||||
"""
|
||||
# 如果传入了 data 字典
|
||||
if data is not None and isinstance(data, dict):
|
||||
# 复制一份数据,避免修改原始数据
|
||||
processed_data = {}
|
||||
|
||||
for key, value in data.items():
|
||||
# 检查对应的字段是否是 ArrayField
|
||||
field = getattr(self, key, None)
|
||||
|
||||
# 如果是 ArrayField 且值是列表,转换为 JSON 字符串
|
||||
# 这样可以避免父类方法将数组拆分成单个值
|
||||
if isinstance(field, ArrayField) and isinstance(value, list):
|
||||
processed_data[key] = json.dumps(value)
|
||||
else:
|
||||
# 其他字段保持原值
|
||||
processed_data[key] = value
|
||||
|
||||
# 使用处理后的数据调用父类方法
|
||||
super().process(formdata, obj, processed_data, **kwargs)
|
||||
|
||||
# 重要:父类处理完成后,将 ArrayField 的 data 从 JSON 字符串转换回列表
|
||||
for key, value in data.items():
|
||||
field = getattr(self, key, None)
|
||||
if isinstance(field, ArrayField) and isinstance(value, list):
|
||||
# 直接设置 data 属性为原始列表
|
||||
field.data = value
|
||||
else:
|
||||
# 没有 data 参数时,直接调用父类方法
|
||||
super().process(formdata, obj, data, **kwargs)
|
||||
@@ -0,0 +1,177 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 一米阳光团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
"""
|
||||
通用表单验证模块
|
||||
=============================================================
|
||||
|
||||
提供通用的表单验证功能,包括:
|
||||
- 自定义数组字段类 ArrayField
|
||||
- 自定义表单基类 BaseForm(支持JSON数组)
|
||||
"""
|
||||
|
||||
import json
|
||||
from wtforms import Field
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 自定义数组字段类
|
||||
# ============================================================
|
||||
|
||||
class ArrayField(Field):
|
||||
"""
|
||||
自定义数组字段,用于接收 JSON 数组数据
|
||||
|
||||
使用方式:
|
||||
city = ArrayField(label='行政区划', validators=[DataRequired()])
|
||||
roles = ArrayField(label='用户角色', validators=[Optional()])
|
||||
|
||||
工作原理:
|
||||
1. process_data: 处理通过 data= 参数传入的数据
|
||||
2. process_formdata: 处理传统表单提交的数据
|
||||
3. _value: 用于表单渲染时显示
|
||||
"""
|
||||
|
||||
def __init__(self, label=None, validators=None, **kwargs):
|
||||
"""
|
||||
初始化数组字段
|
||||
|
||||
Args:
|
||||
label: 字段标签
|
||||
validators: 验证器列表
|
||||
**kwargs: 其他参数
|
||||
"""
|
||||
super().__init__(label, validators, **kwargs)
|
||||
self.data = [] # 默认空数组
|
||||
|
||||
def process_data(self, value):
|
||||
"""
|
||||
处理直接赋值的数据(通过 data= 参数传入)
|
||||
|
||||
这是关键方法,当使用 UserForm(data=json_data) 时会调用此方法。
|
||||
|
||||
Args:
|
||||
value: 传入的值,可能是 list、str、None 或其他类型
|
||||
"""
|
||||
# 空值处理
|
||||
if value is None:
|
||||
self.data = []
|
||||
return
|
||||
|
||||
# 如果已经是列表,直接使用(这是JSON数组的情况)
|
||||
if isinstance(value, list):
|
||||
self.data = value
|
||||
return
|
||||
|
||||
# 如果是字符串,尝试解析为 JSON
|
||||
if isinstance(value, str):
|
||||
# 去除首尾空格
|
||||
value = value.strip()
|
||||
if not value:
|
||||
self.data = []
|
||||
return
|
||||
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
# 确保解析结果是列表
|
||||
if isinstance(parsed, list):
|
||||
self.data = parsed
|
||||
else:
|
||||
# 单个值转为列表
|
||||
self.data = [parsed] if parsed is not None else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# 不是 JSON,作为单值列表
|
||||
self.data = [value] if value else []
|
||||
return
|
||||
|
||||
# 其他类型(如 int、float 等)转为单值列表
|
||||
self.data = [value] if value is not None else []
|
||||
|
||||
def process_formdata(self, valuelist):
|
||||
"""
|
||||
处理表单数据(通过 request.form 提交的数据)
|
||||
|
||||
Args:
|
||||
valuelist: 表单值列表,通常是一个列表
|
||||
"""
|
||||
# 空值处理
|
||||
if not valuelist:
|
||||
self.data = []
|
||||
return
|
||||
|
||||
# 获取第一个值
|
||||
value = valuelist[0]
|
||||
|
||||
# 如果是列表类型(来自多选或多文件上传)
|
||||
if isinstance(value, list):
|
||||
self.data = value
|
||||
return
|
||||
|
||||
# 如果是字符串,尝试解析 JSON
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
self.data = []
|
||||
return
|
||||
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, list):
|
||||
self.data = parsed
|
||||
else:
|
||||
self.data = [parsed] if parsed is not None else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
self.data = [value] if value else []
|
||||
return
|
||||
|
||||
# 其他类型转为单值列表
|
||||
self.data = [value] if value is not None else []
|
||||
|
||||
def _value(self):
|
||||
"""
|
||||
渲染表单时使用,将列表转为 JSON 字符串
|
||||
|
||||
Returns:
|
||||
str: JSON 字符串表示的数组
|
||||
"""
|
||||
if self.data and isinstance(self.data, list):
|
||||
return json.dumps(self.data, ensure_ascii=False)
|
||||
return '[]'
|
||||
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
utils 工具函数包
|
||||
=============================================================
|
||||
|
||||
提供与具体业务解耦的通用工具函数:
|
||||
- request.py 请求解析
|
||||
- model_utils.py 模型工具(models_to_list 等)
|
||||
- case_converter.py 命名转换(camelCase <-> snake_case)
|
||||
- regular.py WTForms 错误提取
|
||||
- captcha.py 验证码生成
|
||||
- file.py / rich_text.py / formatter.py 文件与富文本处理
|
||||
- ip.py / ip2region.py / user_agent.py IP 与 UA 解析
|
||||
- json.py JSON 序列化
|
||||
|
||||
说明:R / jwt / password / security / redis_client 属基础设施,
|
||||
已归入 src/core/,不在此包。
|
||||
"""
|
||||
@@ -0,0 +1,230 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 一米阳光团队 荣誉出品
|
||||
# +======================================================================
|
||||
|
||||
"""
|
||||
验证码生成与验证模块
|
||||
=============================================================
|
||||
|
||||
功能说明:
|
||||
1. 生成带干扰的验证码图片,返回 Base64 格式数据
|
||||
2. 验证用户输入的验证码是否正确
|
||||
3. 防刷机制:限制同一 IP 获取验证码的频率
|
||||
|
||||
技术特点:
|
||||
- 使用 secrets 模块生成安全随机数,替代 random 模块
|
||||
- 字符集去除 0/O/1/I/l/5/S/2/Z 等易混淆字符,提升用户体验
|
||||
- 验证码图片包含干扰线和噪点,防止 OCR 识别
|
||||
- 验证码存储于 Redis,支持过期自动失效
|
||||
- 验证成功后自动删除,确保一次性使用,防止重放攻击
|
||||
- 大小写不敏感比较,提升用户体验
|
||||
|
||||
配置来源:
|
||||
所有配置参数从 config 模块导入,由 .env 文件控制
|
||||
包括:验证码长度、过期时间、防刷间隔、字符集、字体、图片尺寸等
|
||||
|
||||
依赖:
|
||||
- utils.redis_client: Redis 客户端,用于存储验证码
|
||||
- config: 验证码配置参数(从 .env 读取)
|
||||
- PIL: 图片生成与处理
|
||||
|
||||
接口说明:
|
||||
generate_captcha() -> dict
|
||||
生成验证码,返回 {"idKey": "唯一标识", "data": "base64图片数据"}
|
||||
调用频率受 CAPTCHA_RATE_LIMIT 限制
|
||||
|
||||
verify_captcha(id_key, user_input) -> bool
|
||||
验证用户输入的验证码是否正确
|
||||
验证成功后自动删除 Redis 中的记录
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image, ImageFont, ImageDraw, ImageFilter
|
||||
import base64
|
||||
|
||||
from src.core.redis_client import redis
|
||||
from config import (
|
||||
CAPTCHA_LENGTH,
|
||||
CAPTCHA_EXPIRE,
|
||||
CAPTCHA_RATE_LIMIT,
|
||||
CAPTCHA_RATE_WINDOW,
|
||||
CAPTCHA_CHARS,
|
||||
CAPTCHA_FONT_PATH,
|
||||
CAPTCHA_IMAGE_WIDTH,
|
||||
CAPTCHA_IMAGE_HEIGHT,
|
||||
CAPTCHA_FONT_SIZE,
|
||||
CAPTCHA_NOISE_LINES,
|
||||
CAPTCHA_NOISE_DOTS,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 内部函数
|
||||
# ============================================================
|
||||
|
||||
def get_random_color():
|
||||
"""获取随机颜色RGB(使用secrets更安全)"""
|
||||
return (
|
||||
secrets.randbelow(256),
|
||||
secrets.randbelow(256),
|
||||
secrets.randbelow(256)
|
||||
)
|
||||
|
||||
|
||||
def generate_image(length):
|
||||
"""生成验证码图片
|
||||
|
||||
Args:
|
||||
length: 验证码长度
|
||||
|
||||
Returns:
|
||||
tuple: (PIL.Image对象, 验证码字符串)
|
||||
"""
|
||||
# 从字符集随机抽取
|
||||
code = ''.join(secrets.choice(CAPTCHA_CHARS) for _ in range(length))
|
||||
|
||||
# 创建画布(浅灰色背景,减少对比度干扰)
|
||||
image = Image.new('RGB', (CAPTCHA_IMAGE_WIDTH, CAPTCHA_IMAGE_HEIGHT), color=(240, 240, 240))
|
||||
|
||||
# 加载字体
|
||||
try:
|
||||
font = ImageFont.truetype(CAPTCHA_FONT_PATH, size=CAPTCHA_FONT_SIZE)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# 绘制验证码字符(随机偏移,防OCR识别)
|
||||
char_width = (CAPTCHA_IMAGE_WIDTH - 20) // length
|
||||
for i, c in enumerate(code):
|
||||
x = 10 + i * char_width + secrets.randbelow(8)
|
||||
y = secrets.randbelow(10) + 2
|
||||
draw.text(
|
||||
(x, y),
|
||||
text=c,
|
||||
fill=get_random_color(),
|
||||
font=font
|
||||
)
|
||||
|
||||
# 绘制干扰线
|
||||
for _ in range(CAPTCHA_NOISE_LINES):
|
||||
x1 = secrets.randbelow(CAPTCHA_IMAGE_WIDTH)
|
||||
y1 = secrets.randbelow(CAPTCHA_IMAGE_HEIGHT // 2)
|
||||
x2 = secrets.randbelow(CAPTCHA_IMAGE_WIDTH)
|
||||
y2 = secrets.randbelow(CAPTCHA_IMAGE_HEIGHT // 2) + CAPTCHA_IMAGE_HEIGHT // 2
|
||||
draw.line(((x1, y1), (x2, y2)), fill=get_random_color(), width=1)
|
||||
|
||||
# 添加噪点
|
||||
for _ in range(CAPTCHA_NOISE_DOTS):
|
||||
x = secrets.randbelow(CAPTCHA_IMAGE_WIDTH)
|
||||
y = secrets.randbelow(CAPTCHA_IMAGE_HEIGHT)
|
||||
draw.point((x, y), fill=get_random_color())
|
||||
|
||||
# 滤镜增强边缘
|
||||
image = image.filter(ImageFilter.EDGE_ENHANCE)
|
||||
|
||||
return image, code
|
||||
|
||||
|
||||
def get_client_ip():
|
||||
"""获取客户端真实IP(支持代理)"""
|
||||
from flask import request
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
return request.remote_addr
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 对外接口
|
||||
# ============================================================
|
||||
|
||||
def generate_captcha():
|
||||
"""生成验证码实例
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"idKey": str, # 验证码唯一标识
|
||||
"data": str # base64图片数据
|
||||
}
|
||||
"""
|
||||
# ============================================================
|
||||
# 防刷:滑动窗口计数,限制同一IP在窗口期内的请求次数
|
||||
# ============================================================
|
||||
client_ip = get_client_ip()
|
||||
rate_key = f"captcha:rate:{client_ip}"
|
||||
count = redis.get(rate_key)
|
||||
|
||||
if count and int(count) >= CAPTCHA_RATE_LIMIT:
|
||||
ttl = redis.ttl(rate_key)
|
||||
remaining = ttl if ttl > 0 else CAPTCHA_RATE_WINDOW
|
||||
raise Exception(f"请求过于频繁,请等待 {remaining} 秒后再试")
|
||||
|
||||
# ============================================================
|
||||
# 生成验证码
|
||||
# ============================================================
|
||||
image, code = generate_image(CAPTCHA_LENGTH)
|
||||
|
||||
# 转换为base64
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, format='PNG')
|
||||
buf_bytes = buffer.getvalue()
|
||||
data = base64.b64encode(buf_bytes).decode('ascii')
|
||||
|
||||
# 生成唯一ID(使用secrets更安全)
|
||||
id_key = secrets.token_urlsafe(24)
|
||||
|
||||
# 存储验证码到Redis
|
||||
redis.set(id_key, code, expire=CAPTCHA_EXPIRE)
|
||||
|
||||
# 递增计数器,首次时设置过期时间(滑动窗口)
|
||||
if count:
|
||||
redis.incr(rate_key)
|
||||
else:
|
||||
redis.set(rate_key, 1, expire=CAPTCHA_RATE_WINDOW)
|
||||
|
||||
return {
|
||||
"idKey": id_key,
|
||||
"data": data
|
||||
}
|
||||
|
||||
|
||||
def verify_captcha(id_key, user_input):
|
||||
"""验证验证码(验证成功后自动删除,一次性使用)
|
||||
|
||||
Args:
|
||||
id_key: 验证码ID
|
||||
user_input: 用户输入的验证码
|
||||
|
||||
Returns:
|
||||
bool: 验证是否通过
|
||||
"""
|
||||
if not id_key or not user_input:
|
||||
return False
|
||||
|
||||
# 从Redis获取验证码
|
||||
stored_code = redis.get(id_key)
|
||||
|
||||
if not stored_code:
|
||||
return False
|
||||
|
||||
# 转为大写比较(大小写不敏感)
|
||||
stored_code = str(stored_code).upper().strip()
|
||||
user_input = str(user_input).upper().strip()
|
||||
|
||||
# 验证通过后删除(一次性使用,防止重放攻击)
|
||||
if stored_code == user_input:
|
||||
redis.delete(id_key)
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
命名格式转换工具模块
|
||||
=============================================================
|
||||
|
||||
提供字符串命名格式之间的转换功能,主要用于数据库字段名(下划线命名)
|
||||
与前端字段名(驼峰命名)之间的转换。
|
||||
|
||||
主要功能:
|
||||
1. 下划线命名 → 驼峰命名
|
||||
2. 驼峰命名 → 下划线命名
|
||||
3. 下划线命名 → 帕斯卡命名(大驼峰)
|
||||
4. 递归转换字典的所有键名
|
||||
"""
|
||||
|
||||
|
||||
def convert_camel_case(snake_str):
|
||||
"""
|
||||
将下划线命名转换为驼峰命名
|
||||
|
||||
Args:
|
||||
snake_str: 下划线命名字符串,如 'create_time'
|
||||
|
||||
Returns:
|
||||
驼峰命名字符串,如 'createTime'
|
||||
|
||||
Examples:
|
||||
>>> convert_camel_case('create_time')
|
||||
'createTime'
|
||||
>>> convert_camel_case('user_id')
|
||||
'userId'
|
||||
>>> convert_camel_case('id')
|
||||
'id'
|
||||
>>> convert_camel_case('')
|
||||
''
|
||||
"""
|
||||
if not snake_str:
|
||||
return ''
|
||||
|
||||
components = snake_str.split('_')
|
||||
return components[0] + ''.join(_capitalize_first_letter(x) for x in components[1:])
|
||||
|
||||
|
||||
def convert_snake_case(camel_str):
|
||||
"""
|
||||
将驼峰命名转换为下划线命名
|
||||
|
||||
Args:
|
||||
camel_str: 驼峰命名字符串,如 'createTime'
|
||||
|
||||
Returns:
|
||||
下划线命名字符串,如 'create_time'
|
||||
|
||||
Examples:
|
||||
>>> convert_snake_case('createTime')
|
||||
'create_time'
|
||||
>>> convert_snake_case('userId')
|
||||
'user_id'
|
||||
>>> convert_snake_case('id')
|
||||
'id'
|
||||
>>> convert_snake_case('')
|
||||
''
|
||||
"""
|
||||
if not camel_str:
|
||||
return ''
|
||||
|
||||
result = []
|
||||
for i, char in enumerate(camel_str):
|
||||
if char.isupper() and i > 0:
|
||||
result.append('_')
|
||||
result.append(char.lower())
|
||||
else:
|
||||
result.append(char.lower())
|
||||
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
def convert_pascal_case(snake_str):
|
||||
"""
|
||||
将下划线命名转换为帕斯卡命名(大驼峰)
|
||||
|
||||
Args:
|
||||
snake_str: 下划线命名字符串,如 'create_time'
|
||||
|
||||
Returns:
|
||||
帕斯卡命名字符串,如 'CreateTime'
|
||||
|
||||
Examples:
|
||||
>>> convert_pascal_case('create_time')
|
||||
'CreateTime'
|
||||
>>> convert_pascal_case('user_id')
|
||||
'UserId'
|
||||
>>> convert_pascal_case('')
|
||||
''
|
||||
"""
|
||||
if not snake_str:
|
||||
return ''
|
||||
|
||||
components = snake_str.split('_')
|
||||
return ''.join(_capitalize_first_letter(x) for x in components)
|
||||
|
||||
|
||||
def convert_dict_keys(data, converter=convert_camel_case):
|
||||
"""
|
||||
递归转换字典的所有键名
|
||||
|
||||
Args:
|
||||
data: 需要转换的字典或列表
|
||||
converter: 转换函数,默认为 to_camel_case
|
||||
|
||||
Returns:
|
||||
转换后的字典或列表
|
||||
|
||||
Examples:
|
||||
>>> data = {'create_time': '2024-01-01', 'user_id': 1}
|
||||
>>> convert_dict_keys(data)
|
||||
{'createTime': '2024-01-01', 'userId': 1}
|
||||
|
||||
>>> data = [{'user_name': '张三'}, {'user_name': '李四'}]
|
||||
>>> convert_dict_keys(data)
|
||||
[{'userName': '张三'}, {'userName': '李四'}]
|
||||
"""
|
||||
if isinstance(data, list):
|
||||
return [convert_dict_keys(item, converter) for item in data]
|
||||
|
||||
if isinstance(data, dict):
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
new_key = converter(key)
|
||||
result[new_key] = convert_dict_keys(value, converter)
|
||||
return result
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def convert_snake_to_camel(data):
|
||||
"""
|
||||
将字典中的下划线命名字段转换为驼峰命名(便捷方法)
|
||||
|
||||
这是 convert_dict_keys 的便捷封装,专门用于数据库字段转前端字段。
|
||||
|
||||
Args:
|
||||
data: 需要转换的字典或列表
|
||||
|
||||
Returns:
|
||||
转换后的字典或列表(所有键名转为驼峰命名)
|
||||
|
||||
Examples:
|
||||
>>> data = {'create_time': '2024-01-01', 'user_id': 1}
|
||||
>>> convert_dict_keys_snake_to_camel(data)
|
||||
{'createTime': '2024-01-01', 'userId': 1}
|
||||
|
||||
>>> data = [{'user_name': '张三'}, {'user_name': '李四'}]
|
||||
>>> convert_dict_keys_snake_to_camel(data)
|
||||
[{'userName': '张三'}, {'userName': '李四'}]
|
||||
"""
|
||||
return convert_dict_keys(data, converter=convert_camel_case)
|
||||
|
||||
|
||||
def convert_camel_to_snake(data):
|
||||
"""
|
||||
将字典中的驼峰命名字段转换为下划线命名(便捷方法)
|
||||
|
||||
这是 convert_dict_keys 的便捷封装,专门用于前端字段转数据库字段。
|
||||
|
||||
Args:
|
||||
data: 需要转换的字典或列表
|
||||
|
||||
Returns:
|
||||
转换后的字典或列表(所有键名转为下划线命名)
|
||||
|
||||
Examples:
|
||||
>>> data = {'createTime': '2024-01-01', 'userId': 1}
|
||||
>>> convert_dict_keys_camel_to_snake(data)
|
||||
{'create_time': '2024-01-01', 'user_id': 1}
|
||||
|
||||
>>> data = [{'userName': '张三'}, {'userName': '李四'}]
|
||||
>>> convert_dict_keys_camel_to_snake(data)
|
||||
[{'user_name': '张三'}, {'user_name': '李四'}]
|
||||
"""
|
||||
return convert_dict_keys(data, converter=convert_snake_case)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 私有方法
|
||||
# ============================================================
|
||||
|
||||
def _capitalize_first_letter(s):
|
||||
"""
|
||||
将字符串的首字母大写,其余字母小写
|
||||
|
||||
Args:
|
||||
s: 输入字符串
|
||||
|
||||
Returns:
|
||||
首字母大写后的字符串
|
||||
|
||||
Examples:
|
||||
>>> _capitalize_first_letter('name')
|
||||
'Name'
|
||||
>>> _capitalize_first_letter('userName')
|
||||
'Username'
|
||||
>>> _capitalize_first_letter('')
|
||||
''
|
||||
"""
|
||||
if not s:
|
||||
return ''
|
||||
return s[0].upper() + s[1:].lower()
|
||||
|
||||
|
||||
def _is_camel_case(s):
|
||||
"""
|
||||
判断字符串是否为驼峰命名格式
|
||||
|
||||
驼峰命名特征:
|
||||
1. 首字母小写
|
||||
2. 不包含下划线
|
||||
3. 包含大写字母(至少一个大写字母)
|
||||
|
||||
Args:
|
||||
s: 输入字符串
|
||||
|
||||
Returns:
|
||||
bool: 是否为驼峰命名格式
|
||||
|
||||
Examples:
|
||||
>>> _is_camel_case('createTime')
|
||||
True
|
||||
>>> _is_camel_case('create_time')
|
||||
False
|
||||
>>> _is_camel_case('id')
|
||||
False
|
||||
"""
|
||||
if not s:
|
||||
return False
|
||||
|
||||
# 首字母必须小写,不包含下划线,且至少有一个大写字母
|
||||
return (s[0].islower() and
|
||||
'_' not in s and
|
||||
any(char.isupper() for char in s))
|
||||
|
||||
|
||||
def _is_snake_case(s):
|
||||
"""
|
||||
判断字符串是否为下划线命名格式
|
||||
|
||||
下划线命名特征:
|
||||
1. 全部小写
|
||||
2. 包含下划线
|
||||
3. 不以_开头,不以_结尾
|
||||
|
||||
Args:
|
||||
s: 输入字符串
|
||||
|
||||
Returns:
|
||||
bool: 是否为下划线命名格式
|
||||
|
||||
Examples:
|
||||
>>> _is_snake_case('create_time')
|
||||
True
|
||||
>>> _is_snake_case('createTime')
|
||||
False
|
||||
>>> _is_snake_case('id')
|
||||
False
|
||||
"""
|
||||
if not s:
|
||||
return False
|
||||
|
||||
# 全部小写,包含下划线,且不以_开头结尾
|
||||
return (s.islower() and
|
||||
'_' in s and
|
||||
not s.startswith('_') and
|
||||
not s.endswith('_'))
|
||||
|
||||
|
||||
def _is_pascal_case(s):
|
||||
"""
|
||||
判断字符串是否为帕斯卡命名格式(大驼峰)
|
||||
|
||||
帕斯卡命名特征:
|
||||
1. 首字母大写
|
||||
2. 不包含下划线
|
||||
3. 至少有一个字母
|
||||
|
||||
Args:
|
||||
s: 输入字符串
|
||||
|
||||
Returns:
|
||||
bool: 是否为帕斯卡命名格式
|
||||
|
||||
Examples:
|
||||
>>> _is_pascal_case('CreateTime')
|
||||
True
|
||||
>>> _is_pascal_case('createTime')
|
||||
False
|
||||
>>> _is_pascal_case('create_time')
|
||||
False
|
||||
"""
|
||||
if not s:
|
||||
return False
|
||||
|
||||
# 首字母大写,不包含下划线
|
||||
return (s[0].isupper() and
|
||||
'_' not in s and
|
||||
any(char.isalpha() for char in s))
|
||||
|
||||
|
||||
def _convert_single_field(field_name, converter=convert_camel_case):
|
||||
"""
|
||||
转换单个字段名
|
||||
|
||||
Args:
|
||||
field_name: 字段名
|
||||
converter: 转换函数,默认为 to_camel_case
|
||||
|
||||
Returns:
|
||||
转换后的字段名
|
||||
|
||||
Examples:
|
||||
>>> _convert_single_field('create_time')
|
||||
'createTime'
|
||||
>>> _convert_single_field('createTime', convert_snake_case)
|
||||
'create_time'
|
||||
"""
|
||||
if not field_name:
|
||||
return ''
|
||||
|
||||
return converter(field_name)
|
||||
@@ -0,0 +1,407 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 珊瑚云团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 珊瑚云团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
"""
|
||||
文件目录操作工具模块
|
||||
=============================================================
|
||||
|
||||
该模块提供文件和目录操作的常用工具函数。
|
||||
主要用于处理文件上传、目录创建、路径管理等场景,确保文件系统操作的
|
||||
安全性和可靠性。包含目录创建、文件操作等基础功能。
|
||||
|
||||
主要功能:
|
||||
1. 递归创建目录(mkdir)- 确保目标目录存在
|
||||
2. 文件路径处理
|
||||
3. 目录存在性检查
|
||||
|
||||
所属包: utils
|
||||
所在文件: utils/file_utils.py
|
||||
依赖模块: os - 操作系统接口模块
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 常见文件类型的魔术字节签名
|
||||
# 格式:扩展名 -> [签名列表],签名为 (offset, bytes) 元组
|
||||
MAGIC_SIGNATURES = {
|
||||
# 图片
|
||||
'.jpg': [(0, b'\xff\xd8\xff')],
|
||||
'.jpeg': [(0, b'\xff\xd8\xff')],
|
||||
'.png': [(0, b'\x89PNG\r\n\x1a\n')],
|
||||
'.gif': [(0, b'GIF87a'), (0, b'GIF89a')],
|
||||
'.bmp': [(0, b'BM')],
|
||||
'.webp': [(0, b'RIFF')],
|
||||
'.ico': [(0, b'\x00\x00\x01\x00'), (0, b'\x00\x00\x02\x00')],
|
||||
# 文档
|
||||
'.pdf': [(0, b'%PDF')],
|
||||
'.zip': [(0, b'PK\x03\x04')],
|
||||
'.rar': [(0, b'Rar!\x1a\x07')],
|
||||
'.7z': [(0, b'7z\xbc\xaf\x27\x1c')],
|
||||
'.mp3': [(0, b'\xff\xfb'), (0, b'\xff\xf3'), (0, b'\xff\xf2'), (0, b'ID3')],
|
||||
'.mp4': [(4, b'ftyp')],
|
||||
# SVG 是文本格式,无固定二进制签名
|
||||
}
|
||||
|
||||
|
||||
def verify_file_magic(file_stream, ext_with_dot):
|
||||
"""
|
||||
校验文件魔术字节,验证文件实际内容与扩展名是否一致
|
||||
|
||||
Args:
|
||||
file_stream: Flask上传文件对象(FileStorage),需支持seek/read
|
||||
ext_with_dot: 带点的扩展名,如 '.jpg'
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, message)
|
||||
- is_valid (bool): 是否通过校验
|
||||
- message (str): 提示信息
|
||||
"""
|
||||
signatures = MAGIC_SIGNATURES.get(ext_with_dot)
|
||||
if not signatures:
|
||||
# 无签名规则的文件类型,跳过魔术字节校验
|
||||
return True, ''
|
||||
|
||||
try:
|
||||
# 读取文件头部字节用于校验
|
||||
header = file_stream.read(32)
|
||||
file_stream.seek(0)
|
||||
|
||||
if not header:
|
||||
return False, '文件内容为空'
|
||||
|
||||
for sig in signatures:
|
||||
if isinstance(sig, tuple):
|
||||
offset, sig_bytes = sig
|
||||
if header[offset:offset + len(sig_bytes)] == sig_bytes:
|
||||
return True, ''
|
||||
elif header.startswith(sig):
|
||||
return True, ''
|
||||
|
||||
return False, f'文件内容与扩展名 {ext_with_dot} 不匹配'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'魔术字节校验异常: {e}')
|
||||
# 读取异常时不阻断上传,降级为仅扩展名校验
|
||||
return True, ''
|
||||
|
||||
|
||||
def mkdir(path):
|
||||
"""
|
||||
递归创建目录
|
||||
=============================================================
|
||||
|
||||
检查指定路径的目录是否存在,如果不存在则递归创建所有必需的目录。
|
||||
用于确保文件上传等操作前目标目录已存在。
|
||||
|
||||
参数:
|
||||
path (str): 需要创建/检查的目录路径
|
||||
|
||||
返回:
|
||||
bool: True - 目录创建成功或已存在
|
||||
False - 目录创建失败(保留扩展用)
|
||||
|
||||
使用示例:
|
||||
>>> # 确保上传目录存在
|
||||
>>> upload_dir = '/data/uploads/2024/01'
|
||||
>>> mkdir(upload_dir)
|
||||
/data/uploads/2024/01 创建成功
|
||||
True
|
||||
|
||||
注意事项:
|
||||
1. 函数会自动创建多级不存在的目录
|
||||
2. 如果目录已存在,不会重复创建
|
||||
3. 需要有相应的文件系统权限
|
||||
"""
|
||||
|
||||
# 去除首位空格
|
||||
# 说明:消除用户输入中可能包含的前后空格,避免路径错误
|
||||
path = path.strip()
|
||||
|
||||
# 去除尾部 \ 符号
|
||||
# 说明:Windows系统路径可能以\结尾,去除后保持路径格式统一
|
||||
path = path.rstrip("\\")
|
||||
|
||||
# 判断路径是否存在
|
||||
# 函数:os.path.exists(path)
|
||||
# 说明:检查指定路径的文件或目录是否存在
|
||||
isExists = os.path.exists(path)
|
||||
|
||||
if not isExists:
|
||||
# 如果不存在则创建目录
|
||||
# 函数:os.makedirs(path)
|
||||
# 说明:递归创建目录,如果中间目录不存在也会一并创建
|
||||
# 例如:创建 /a/b/c 时,如果 /a 和 /a/b 都不存在,会一并创建
|
||||
os.makedirs(path)
|
||||
logger.debug(f'{path} 创建成功')
|
||||
return True
|
||||
else:
|
||||
logger.debug(f'{path} 目录已存在')
|
||||
return False
|
||||
|
||||
|
||||
# ======================= 扩展:常用文件操作工具类 ==========================
|
||||
|
||||
class FileUtils:
|
||||
"""
|
||||
文件操作工具类
|
||||
=============================================================
|
||||
|
||||
提供更多常用的文件和目录操作功能,封装为类方法便于调用。
|
||||
包含文件读写、路径处理、文件信息获取等实用功能。
|
||||
|
||||
使用示例:
|
||||
>>> # 获取文件大小
|
||||
>>> size = FileUtils.get_file_size('/path/file.txt')
|
||||
>>> print(f"{size} bytes")
|
||||
|
||||
>>> # 安全删除文件
|
||||
>>> if FileUtils.safe_delete('/path/file.txt'):
|
||||
... print("文件删除成功")
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_file_size(file_path):
|
||||
"""
|
||||
获取文件大小
|
||||
=============================================================
|
||||
|
||||
参数:
|
||||
file_path (str): 文件路径
|
||||
|
||||
返回:
|
||||
int: 文件大小(字节),如果文件不存在返回0
|
||||
"""
|
||||
if os.path.exists(file_path) and os.path.isfile(file_path):
|
||||
return os.path.getsize(file_path)
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def get_file_extension(filename):
|
||||
"""
|
||||
获取文件扩展名
|
||||
=============================================================
|
||||
|
||||
参数:
|
||||
filename (str): 文件名或文件路径
|
||||
|
||||
返回:
|
||||
str: 文件扩展名(小写),例如:'.jpg', '.txt'
|
||||
"""
|
||||
import os
|
||||
return os.path.splitext(filename)[1].lower()
|
||||
|
||||
@staticmethod
|
||||
def get_file_name_without_ext(filename):
|
||||
"""
|
||||
获取不带扩展名的文件名
|
||||
=============================================================
|
||||
|
||||
参数:
|
||||
filename (str): 文件名或文件路径
|
||||
|
||||
返回:
|
||||
str: 不带扩展名的文件名
|
||||
"""
|
||||
import os
|
||||
return os.path.splitext(os.path.basename(filename))[0]
|
||||
|
||||
@staticmethod
|
||||
def safe_delete(file_path):
|
||||
"""
|
||||
安全删除文件
|
||||
=============================================================
|
||||
|
||||
参数:
|
||||
file_path (str): 文件路径
|
||||
|
||||
返回:
|
||||
bool: True - 删除成功或文件不存在
|
||||
False - 删除失败(如权限不足)
|
||||
"""
|
||||
try:
|
||||
if os.path.exists(file_path) and os.path.isfile(file_path):
|
||||
os.remove(file_path)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"文件删除失败: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def read_file(file_path, encoding='utf-8'):
|
||||
"""
|
||||
读取文件内容
|
||||
=============================================================
|
||||
|
||||
参数:
|
||||
file_path (str): 文件路径
|
||||
encoding (str): 文件编码,默认utf-8
|
||||
|
||||
返回:
|
||||
str/None: 文件内容字符串,读取失败返回None
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r', encoding=encoding) as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
logger.warning(f"文件读取失败: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def write_file(file_path, content, encoding='utf-8', mode='w'):
|
||||
"""
|
||||
写入文件内容
|
||||
=============================================================
|
||||
|
||||
参数:
|
||||
file_path (str): 文件路径
|
||||
content (str): 要写入的内容
|
||||
encoding (str): 文件编码,默认utf-8
|
||||
mode (str): 写入模式,'w'覆盖写入,'a'追加写入
|
||||
|
||||
返回:
|
||||
bool: True - 写入成功,False - 写入失败
|
||||
"""
|
||||
try:
|
||||
# 确保目录存在
|
||||
directory = os.path.dirname(file_path)
|
||||
if directory and not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
|
||||
with open(file_path, mode, encoding=encoding) as f:
|
||||
f.write(content)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"文件写入失败: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def list_files(directory, pattern=None):
|
||||
"""
|
||||
列出目录下的所有文件
|
||||
=============================================================
|
||||
|
||||
参数:
|
||||
directory (str): 目录路径
|
||||
pattern (str): 文件匹配模式,如 '*.txt',None表示所有文件
|
||||
|
||||
返回:
|
||||
list: 文件名列表
|
||||
"""
|
||||
import glob
|
||||
|
||||
if not os.path.exists(directory):
|
||||
return []
|
||||
|
||||
if pattern:
|
||||
search_path = os.path.join(directory, pattern)
|
||||
return [os.path.basename(f) for f in glob.glob(search_path)]
|
||||
else:
|
||||
return [f for f in os.listdir(directory)
|
||||
if os.path.isfile(os.path.join(directory, f))]
|
||||
|
||||
@staticmethod
|
||||
def get_file_info(file_path):
|
||||
"""
|
||||
获取文件详细信息
|
||||
=============================================================
|
||||
|
||||
参数:
|
||||
file_path (str): 文件路径
|
||||
|
||||
返回:
|
||||
dict: 包含文件信息的字典,文件不存在返回None
|
||||
"""
|
||||
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
||||
return None
|
||||
|
||||
stat = os.stat(file_path)
|
||||
return {
|
||||
'name': os.path.basename(file_path),
|
||||
'path': os.path.abspath(file_path),
|
||||
'size': stat.st_size,
|
||||
'size_format': FileUtils._format_size(stat.st_size),
|
||||
'extension': FileUtils.get_file_extension(file_path),
|
||||
'created_time': stat.st_ctime,
|
||||
'modified_time': stat.st_mtime,
|
||||
'is_hidden': os.path.basename(file_path).startswith('.')
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _format_size(size_bytes):
|
||||
"""
|
||||
格式化文件大小显示
|
||||
=============================================================
|
||||
|
||||
参数:
|
||||
size_bytes (int): 文件大小(字节)
|
||||
|
||||
返回:
|
||||
str: 格式化后的大小字符串,如 '1.5 MB'
|
||||
"""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.2f} KB"
|
||||
elif size_bytes < 1024 * 1024 * 1024:
|
||||
return f"{size_bytes / (1024 * 1024):.2f} MB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
|
||||
|
||||
|
||||
# ======================= 便捷函数封装 ==========================
|
||||
|
||||
def get_file_size(file_path):
|
||||
"""获取文件大小(字节)的便捷函数"""
|
||||
return FileUtils.get_file_size(file_path)
|
||||
|
||||
|
||||
def safe_delete(file_path):
|
||||
"""安全删除文件的便捷函数"""
|
||||
return FileUtils.safe_delete(file_path)
|
||||
|
||||
|
||||
def get_file_extension(filename):
|
||||
"""获取文件扩展名的便捷函数"""
|
||||
return FileUtils.get_file_extension(filename)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
日志格式化工具模块
|
||||
=============================================================
|
||||
|
||||
提供请求参数和返回结果的格式化工具,用于操作日志存储:
|
||||
- format_params: 格式化请求参数(过滤敏感字段)
|
||||
- format_result: 格式化返回结果
|
||||
- truncate_json: 截断过长的JSON字符串
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def format_params(params) -> str:
|
||||
"""
|
||||
格式化请求参数,用于日志存储
|
||||
|
||||
将请求参数字典转换为JSON字符串,并过滤敏感信息。
|
||||
|
||||
Args:
|
||||
params: 请求参数(字典或其他类型)
|
||||
|
||||
Returns:
|
||||
格式化后的JSON字符串
|
||||
"""
|
||||
if not params:
|
||||
return ""
|
||||
|
||||
try:
|
||||
sensitive_fields = ['password', 'old_password', 'new_password', 'confirm_password']
|
||||
if isinstance(params, dict):
|
||||
params_copy = params.copy()
|
||||
for field in sensitive_fields:
|
||||
if field in params_copy:
|
||||
params_copy[field] = '******'
|
||||
return json.dumps(params_copy, ensure_ascii=False)
|
||||
return json.dumps(params, ensure_ascii=False)
|
||||
except Exception:
|
||||
return str(params)[:500]
|
||||
|
||||
|
||||
def format_result(result):
|
||||
"""
|
||||
格式化返回结果
|
||||
|
||||
将返回结果转换为适合存储的字符串格式,支持JsonResponse、字典等类型。
|
||||
|
||||
Args:
|
||||
result: 返回结果
|
||||
|
||||
Returns:
|
||||
格式化后的结果字符串
|
||||
"""
|
||||
if not result:
|
||||
return ''
|
||||
|
||||
try:
|
||||
if hasattr(result, 'content') and result.content:
|
||||
content = result.content.decode('utf-8')
|
||||
if content:
|
||||
try:
|
||||
data = json.loads(content)
|
||||
result_str = json.dumps(data, ensure_ascii=False)
|
||||
except Exception:
|
||||
result_str = content
|
||||
|
||||
if len(result_str) > 10000:
|
||||
result_str = truncate_json(result_str, 10000)
|
||||
return result_str
|
||||
return None
|
||||
|
||||
elif isinstance(result, dict):
|
||||
result_str = json.dumps(result, ensure_ascii=False)
|
||||
if len(result_str) > 10000:
|
||||
result_str = truncate_json(result_str, 10000)
|
||||
return result_str
|
||||
|
||||
else:
|
||||
result_str = str(result)
|
||||
if len(result_str) > 10000:
|
||||
result_str = truncate_json(result_str, 10000)
|
||||
return result_str
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"格式化返回结果异常: {e}")
|
||||
return truncate_json(str(result), 10000) if result else ''
|
||||
|
||||
|
||||
def truncate_json(json_str, max_len):
|
||||
"""
|
||||
截断JSON字符串
|
||||
|
||||
Args:
|
||||
json_str: JSON字符串
|
||||
max_len: 最大长度
|
||||
|
||||
Returns:
|
||||
截断后的字符串
|
||||
"""
|
||||
if not json_str:
|
||||
return ''
|
||||
if len(json_str) <= max_len:
|
||||
return json_str
|
||||
return json_str[:max_len] + '...'
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
IP地址工具模块
|
||||
=============================================================
|
||||
|
||||
提供客户端IP获取和地理位置查询:
|
||||
- get_client_ip: 获取客户端真实IP(需配合Flask ProxyFix使用)
|
||||
- get_ip_location: 调用第三方API查询IP地理位置
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_client_ip():
|
||||
"""
|
||||
获取客户端真实IP地址
|
||||
|
||||
通过Flask ProxyFix中间件由PROXY_NUM配置自动设置
|
||||
request.remote_addr为真实客户端IP。未配置ProxyFix时,
|
||||
request.remote_addr即为直连IP,避免客户端伪造。
|
||||
|
||||
Returns:
|
||||
str: 客户端IP地址
|
||||
"""
|
||||
from flask import request
|
||||
|
||||
return request.remote_addr
|
||||
|
||||
|
||||
def get_ip_location(ip: str) -> str:
|
||||
"""
|
||||
根据IP获取地理位置(使用第三方API)
|
||||
|
||||
Args:
|
||||
ip: IP地址
|
||||
|
||||
Returns:
|
||||
地理位置信息,格式:"国家 省份 城市"
|
||||
"""
|
||||
if ip in ['127.0.0.1', 'localhost'] or ip.startswith('192.168.') or ip.startswith('10.'):
|
||||
return "内网IP"
|
||||
|
||||
try:
|
||||
url = f"http://ip-api.com/json/{ip}?lang=zh-CN"
|
||||
with urllib.request.urlopen(url, timeout=3) as response:
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
if data.get('status') == 'success':
|
||||
return f"{data.get('country', '')} {data.get('regionName', '')} {data.get('city', '')}"
|
||||
except Exception as e:
|
||||
logger.warning(f"获取IP地理位置失败: {e}")
|
||||
|
||||
return "未知"
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
IP地理位置查询模块(基于ip2region离线数据库)
|
||||
=============================================================
|
||||
|
||||
基于 ip2region.xdb 离线数据库提供高性能IP地理位置查询,无需网络请求。
|
||||
|
||||
核心功能:
|
||||
- get_ip_region: 根据IP获取完整区域信息字典
|
||||
- get_ip_location: 根据IP获取地理位置文本(兼容现有调用)
|
||||
- get_ip_country / get_ip_province / get_ip_city / get_ip_isp: 按层级获取
|
||||
|
||||
xdb文件位于 src/common/utils/ip2region.xdb,启动时全量加载到内存,查询零IO延迟。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ip2region import searcher, util
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# xdb数据库文件路径(与本模块同目录)
|
||||
_XDB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ip2region.xdb")
|
||||
|
||||
# 模块级单例:启动时加载xdb到内存,后续查询零IO
|
||||
_searcher = None
|
||||
|
||||
|
||||
def _get_searcher():
|
||||
"""获取搜索器单例(懒加载,首次调用时初始化)"""
|
||||
global _searcher
|
||||
if _searcher is not None:
|
||||
return _searcher
|
||||
|
||||
if not os.path.exists(_XDB_PATH):
|
||||
logger.error(f"ip2region数据库文件不存在: {_XDB_PATH}")
|
||||
return None
|
||||
|
||||
try:
|
||||
c_buff = util.load_content_from_file(_XDB_PATH)
|
||||
_searcher = searcher.new_with_buffer(util.IPv4, c_buff)
|
||||
logger.info(f"ip2region数据库加载成功: {_XDB_PATH}")
|
||||
except Exception as e:
|
||||
logger.error(f"ip2region数据库加载失败: {e}")
|
||||
return None
|
||||
|
||||
return _searcher
|
||||
|
||||
|
||||
def _parse_region(region_str):
|
||||
"""
|
||||
解析ip2region原始结果字符串
|
||||
|
||||
原始格式: "国家|省份|城市|ISP|国家代码"
|
||||
示例: "中国|浙江省|杭州市|阿里|CN", "United States|California|0|Google LLC|US"
|
||||
|
||||
Args:
|
||||
region_str: ip2region返回的原始字符串
|
||||
|
||||
Returns:
|
||||
dict: 各层级区域信息
|
||||
"""
|
||||
parts = region_str.split("|") if region_str else []
|
||||
# 补齐到5段
|
||||
while len(parts) < 5:
|
||||
parts.append("0")
|
||||
|
||||
country = parts[0]
|
||||
province = parts[1]
|
||||
city = parts[2]
|
||||
isp = parts[3]
|
||||
country_code = parts[4]
|
||||
|
||||
# 清理占位符 "0" → 空字符串
|
||||
province = "" if province == "0" else province
|
||||
city = "" if city == "0" else city
|
||||
isp = "" if isp == "0" else isp
|
||||
country_code = "" if country_code == "0" else country_code
|
||||
|
||||
return {
|
||||
"country": country,
|
||||
"province": province,
|
||||
"city": city,
|
||||
"isp": isp,
|
||||
"country_code": country_code,
|
||||
}
|
||||
|
||||
|
||||
def _is_private_ip(ip):
|
||||
"""判断是否为内网/保留IP"""
|
||||
if not ip:
|
||||
return True
|
||||
ip = ip.strip()
|
||||
if ip in ("127.0.0.1", "localhost", "0.0.0.0"):
|
||||
return True
|
||||
if ip.startswith("10.") or ip.startswith("192.168."):
|
||||
return True
|
||||
if ip.startswith("172."):
|
||||
parts = ip.split(".")
|
||||
if len(parts) >= 2:
|
||||
second = int(parts[1])
|
||||
if 16 <= second <= 31:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_ip_region(ip):
|
||||
"""
|
||||
根据IP获取完整区域信息字典
|
||||
|
||||
Args:
|
||||
ip: IP地址字符串
|
||||
|
||||
Returns:
|
||||
dict: 区域信息,包含 country/province/city/isp/country_code 字段。
|
||||
内网IP返回 {"country": "内网IP", ...},查询失败返回 {"country": "未知", ...}
|
||||
"""
|
||||
if _is_private_ip(ip):
|
||||
return {"country": "内网IP", "province": "", "city": "", "isp": "", "country_code": ""}
|
||||
|
||||
s = _get_searcher()
|
||||
if s is None:
|
||||
return {"country": "未知", "province": "", "city": "", "isp": "", "country_code": ""}
|
||||
|
||||
try:
|
||||
region_str = s.search(ip)
|
||||
if not region_str:
|
||||
return {"country": "未知", "province": "", "city": "", "isp": "", "country_code": ""}
|
||||
return _parse_region(region_str)
|
||||
except Exception as e:
|
||||
logger.warning(f"IP地理位置查询失败 [{ip}]: {e}")
|
||||
return {"country": "未知", "province": "", "city": "", "isp": "", "country_code": ""}
|
||||
|
||||
|
||||
def get_ip_location(ip):
|
||||
"""
|
||||
根据IP获取地理位置文本描述
|
||||
|
||||
格式: "国家 省份 城市"(与原 utils/ip.py 的 get_ip_location 返回格式兼容)
|
||||
内网IP返回 "内网IP",查询失败返回 "未知"
|
||||
|
||||
Args:
|
||||
ip: IP地址字符串
|
||||
|
||||
Returns:
|
||||
str: 地理位置文本
|
||||
"""
|
||||
region = get_ip_region(ip)
|
||||
|
||||
if region["country"] in ("内网IP", "未知"):
|
||||
return region["country"]
|
||||
|
||||
parts = [region["country"]]
|
||||
if region["province"]:
|
||||
parts.append(region["province"])
|
||||
if region["city"] and region["city"] != region["province"]:
|
||||
parts.append(region["city"])
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def get_ip_country(ip):
|
||||
"""根据IP获取国家"""
|
||||
return get_ip_region(ip)["country"]
|
||||
|
||||
|
||||
def get_ip_province(ip):
|
||||
"""根据IP获取省份"""
|
||||
return get_ip_region(ip)["province"]
|
||||
|
||||
|
||||
def get_ip_city(ip):
|
||||
"""根据IP获取城市"""
|
||||
return get_ip_region(ip)["city"]
|
||||
|
||||
|
||||
def get_ip_isp(ip):
|
||||
"""根据IP获取运营商"""
|
||||
return get_ip_region(ip)["isp"]
|
||||
Binary file not shown.
@@ -0,0 +1,91 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 一米阳光团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
"""
|
||||
JSON 序列化模块
|
||||
自定义 JSON 编码器,处理日期时间等特殊类型的序列化
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from flask.json.provider import DefaultJSONProvider
|
||||
|
||||
|
||||
class JSONEncoder(DefaultJSONProvider):
|
||||
"""
|
||||
自定义 JSON 编码器
|
||||
|
||||
功能说明:
|
||||
扩展 Flask 默认的 JSON 序列化器,支持以下类型的格式化输出:
|
||||
1. datetime 类型 → 'YYYY-MM-DD HH:MM:SS' 格式
|
||||
2. date 类型 → 'YYYY-MM-DD' 格式
|
||||
|
||||
使用方式:
|
||||
app.json = JSONEncoder(app)
|
||||
"""
|
||||
|
||||
def default(self, o):
|
||||
"""
|
||||
处理无法被默认 JSON 序列化的对象
|
||||
|
||||
Args:
|
||||
o: 需要序列化的对象
|
||||
|
||||
Returns:
|
||||
序列化后的值(字符串或其他可序列化类型)
|
||||
"""
|
||||
# ============================================================
|
||||
# 处理日期时间类型(datetime)
|
||||
# ============================================================
|
||||
# 将 datetime 对象格式化为:2024-01-15 14:30:00
|
||||
if isinstance(o, datetime):
|
||||
return o.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# ============================================================
|
||||
# 处理日期类型(date)
|
||||
# ============================================================
|
||||
# 将 date 对象格式化为:2024-01-15
|
||||
if isinstance(o, date):
|
||||
return o.strftime('%Y-%m-%d')
|
||||
|
||||
# ============================================================
|
||||
# 其他类型交给父类处理
|
||||
# ============================================================
|
||||
return super().default(o)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
模型工具模块
|
||||
=============================================================
|
||||
|
||||
提供SQLAlchemy模型对象的序列化工具:
|
||||
- model_to_dict: 将单个模型对象转换为字典
|
||||
- models_to_list: 将模型对象列表转换为字典列表
|
||||
- format_datetime: 格式化datetime对象
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from src.common.constants.common import SERIALIZE_EXCLUDE_FIELDS
|
||||
from src.common.utils.case_converter import convert_camel_case
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def format_datetime(dt, format_str='%Y-%m-%d %H:%M:%S'):
|
||||
"""
|
||||
格式化日期时间
|
||||
|
||||
Args:
|
||||
dt: datetime对象
|
||||
format_str: 日期时间格式字符串,默认为 '%Y-%m-%d %H:%M:%S'
|
||||
|
||||
Returns:
|
||||
格式化后的日期字符串,如果输入为None则返回None
|
||||
"""
|
||||
return dt.strftime(format_str) if dt else None
|
||||
|
||||
|
||||
def model_to_dict(model, exclude=None, convert_case=True):
|
||||
"""
|
||||
将SQLAlchemy模型对象转换为字典
|
||||
|
||||
Args:
|
||||
model: SQLAlchemy模型对象
|
||||
exclude: 需要排除的字段列表,如 ['id', 'is_delete'](使用下划线命名)
|
||||
convert_case: 是否将字段名转换为驼峰命名,默认True
|
||||
|
||||
Returns:
|
||||
转换后的字典对象
|
||||
"""
|
||||
if not model:
|
||||
return None
|
||||
|
||||
result = {}
|
||||
for column in model.__table__.columns:
|
||||
field_name = column.name
|
||||
value = getattr(model, field_name)
|
||||
|
||||
if exclude and field_name in exclude:
|
||||
continue
|
||||
|
||||
if field_name in SERIALIZE_EXCLUDE_FIELDS:
|
||||
continue
|
||||
|
||||
# 所有 datetime 类型统一格式化
|
||||
if isinstance(value, datetime):
|
||||
value = value.strftime('%Y-%m-%d %H:%M:%S') if value else None
|
||||
|
||||
if convert_case:
|
||||
field_name = convert_camel_case(field_name)
|
||||
|
||||
result[field_name] = value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def models_to_list(models, exclude=None, convert_case=True):
|
||||
"""
|
||||
将SQLAlchemy模型对象列表转换为字典列表
|
||||
|
||||
Args:
|
||||
models: SQLAlchemy模型对象列表
|
||||
exclude: 需要排除的字段列表
|
||||
convert_case: 是否将字段名转换为驼峰命名
|
||||
|
||||
Returns:
|
||||
字典列表
|
||||
"""
|
||||
if not models:
|
||||
return []
|
||||
|
||||
return [
|
||||
model_to_dict(model, exclude, convert_case)
|
||||
for model in models
|
||||
]
|
||||
@@ -0,0 +1,80 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 珊瑚云团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 珊瑚云团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
"""
|
||||
表单错误处理模块
|
||||
=============================================================
|
||||
|
||||
提供表单验证错误信息的提取和格式化功能。
|
||||
"""
|
||||
|
||||
|
||||
def get_err(form):
|
||||
"""
|
||||
获取表单验证的错误信息
|
||||
|
||||
遍历表单的所有错误字段,提取第一条错误信息,并用斜杠拼接。
|
||||
|
||||
Args:
|
||||
form: WTForms 表单对象
|
||||
|
||||
Returns:
|
||||
str: 拼接后的错误信息字符串,多个错误用 '/' 分隔
|
||||
|
||||
Examples:
|
||||
>>> from application.forms.level import LevelForm
|
||||
>>> form = LevelForm(request.form)
|
||||
>>> if not form.validate():
|
||||
... err_msg = get_err(form)
|
||||
... return R.failed(msg=err_msg)
|
||||
|
||||
# 错误信息示例
|
||||
# 单条错误: "职级名称不能为空"
|
||||
# 多条错误: "职级名称不能为空/职级排序必须为数字"
|
||||
"""
|
||||
if not form.errors:
|
||||
return ""
|
||||
|
||||
# 提取每个字段的第一条错误信息
|
||||
error_messages = [errors[0] for errors in form.errors.values()]
|
||||
|
||||
# 用斜杠拼接所有错误信息
|
||||
return "/".join(error_messages)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
请求解析工具模块
|
||||
=============================================================
|
||||
|
||||
提供请求数据解析相关的工具函数:
|
||||
- get_json_data: 从Flask请求中解析JSON数据
|
||||
- require_json: 装饰器,要求请求必须包含JSON数据
|
||||
- parse_request_body: 解析请求体JSON数据,返回(data, error)元组
|
||||
- parse_id_list: 将逗号分隔的ID字符串解析为整数列表
|
||||
- parse_batch_ids: 从请求体解析批量删除ID数组,校验并转为逗号分隔字符串
|
||||
- like_escape: 转义SQL LIKE通配符,防止用户输入被当作通配符
|
||||
- like_contains: 构建安全的LIKE包含查询值
|
||||
"""
|
||||
|
||||
import logging
|
||||
from functools import wraps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_json_data(required=True, silent=False):
|
||||
"""
|
||||
获取请求中的JSON数据
|
||||
|
||||
Args:
|
||||
required: 是否必须获取到JSON数据,默认为True
|
||||
silent: 是否静默处理错误,默认为False(False时会检查Content-Type)
|
||||
|
||||
Returns:
|
||||
dict: JSON数据,如果获取失败则返回None或空字典
|
||||
"""
|
||||
from flask import request
|
||||
|
||||
if not silent and not request.is_json:
|
||||
return None
|
||||
|
||||
try:
|
||||
json_data = request.get_json(silent=silent)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if required and not json_data:
|
||||
return None
|
||||
|
||||
return json_data or {}
|
||||
|
||||
|
||||
def require_json(f):
|
||||
"""
|
||||
装饰器:要求请求必须包含JSON数据
|
||||
|
||||
Examples:
|
||||
@require_json
|
||||
def login():
|
||||
data = get_json_data()
|
||||
# 业务逻辑...
|
||||
"""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
from flask import request
|
||||
|
||||
if not request.is_json:
|
||||
from src.core import response as R
|
||||
return R.failed(msg="参数格式不正确,请使用JSON格式")
|
||||
|
||||
json_data = request.get_json()
|
||||
if not json_data:
|
||||
from src.core import response as R
|
||||
return R.failed(msg="参数不能为空")
|
||||
|
||||
request.parsed_json = json_data
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def parse_request_body():
|
||||
"""
|
||||
解析请求体JSON数据
|
||||
|
||||
Returns:
|
||||
tuple: (data, error)
|
||||
成功时 data 为解析后的字典,error 为 None
|
||||
失败时 data 为 None,error 为错误描述字符串
|
||||
"""
|
||||
try:
|
||||
data = get_json_data(required=False, silent=True)
|
||||
|
||||
if data is None or data == {}:
|
||||
return None, "参数不能为空"
|
||||
|
||||
return data, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"解析请求体异常: {str(e)}")
|
||||
return None, "参数处理失败"
|
||||
|
||||
|
||||
def parse_id_list(id_str: str, separator: str = ',') -> list:
|
||||
"""
|
||||
将逗号分隔的ID字符串解析为整数列表
|
||||
|
||||
Args:
|
||||
id_str: 逗号分隔的ID字符串,如 "1,2,3"
|
||||
separator: 分隔符,默认为逗号
|
||||
|
||||
Returns:
|
||||
list: 有效的整数ID列表
|
||||
|
||||
Example:
|
||||
>>> parse_id_list("1,2,3")
|
||||
[1, 2, 3]
|
||||
>>> parse_id_list("1,2,abc,3")
|
||||
[1, 2, 3]
|
||||
"""
|
||||
if not id_str:
|
||||
return []
|
||||
result = []
|
||||
for item in id_str.split(separator):
|
||||
item = item.strip()
|
||||
if item.isdigit():
|
||||
result.append(int(item))
|
||||
return result
|
||||
|
||||
|
||||
def parse_batch_ids():
|
||||
"""
|
||||
从请求体解析批量删除的ID数组,校验并转为逗号分隔字符串
|
||||
|
||||
请求体格式: [1, 2, 3]
|
||||
|
||||
Returns:
|
||||
tuple: (id_str, error_response)
|
||||
成功时 id_str 为 "1,2,3",error_response 为 None
|
||||
失败时 id_str 为 None,error_response 为 R.failed 响应
|
||||
"""
|
||||
from flask import request
|
||||
from src.core import response as R
|
||||
|
||||
ids = request.get_json()
|
||||
if not ids or not isinstance(ids, list) or len(ids) == 0:
|
||||
return None, R.failed("参数错误:ids必须为非空数组")
|
||||
try:
|
||||
id_list = [int(id_val) for id_val in ids]
|
||||
except (ValueError, TypeError):
|
||||
return None, R.failed("参数错误:数组元素必须为数字")
|
||||
|
||||
return ','.join(str(id_val) for id_val in id_list), None
|
||||
|
||||
|
||||
def like_escape(value, escape_char='\\'):
|
||||
"""
|
||||
转义SQL LIKE通配符
|
||||
|
||||
将用户输入中的 %、_ 和转义字符本身进行转义,
|
||||
防止用户输入被当作LIKE通配符导致非预期查询结果。
|
||||
|
||||
Args:
|
||||
value: 原始输入值
|
||||
escape_char: 转义字符,默认为反斜杠
|
||||
|
||||
Returns:
|
||||
str: 转义后的安全字符串
|
||||
|
||||
Example:
|
||||
>>> like_escape('100%')
|
||||
'100\\%'
|
||||
>>> like_escape('test_data')
|
||||
'test\\_data'
|
||||
"""
|
||||
escaped = str(value).replace(escape_char, escape_char * 2)
|
||||
escaped = escaped.replace('%', escape_char + '%')
|
||||
escaped = escaped.replace('_', escape_char + '_')
|
||||
return escaped
|
||||
|
||||
|
||||
def like_contains(value, escape_char='\\'):
|
||||
"""
|
||||
构建安全的LIKE包含查询值(前后加%)
|
||||
|
||||
对用户输入进行通配符转义后,前后添加 % 构建模糊查询值。
|
||||
配合 SQLAlchemy 的 .like() 方法和 escape 参数使用。
|
||||
|
||||
Args:
|
||||
value: 原始输入值
|
||||
escape_char: 转义字符,默认为反斜杠
|
||||
|
||||
Returns:
|
||||
str: 构建好的LIKE查询值,如 '%safe_value%'
|
||||
|
||||
Example:
|
||||
>>> # SQLAlchemy 用法
|
||||
>>> query = query.filter(User.name.like(like_contains(name), escape='\\'))
|
||||
"""
|
||||
return f'%{like_escape(value, escape_char)}%'
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
富文本与文件处理模块
|
||||
=============================================================
|
||||
|
||||
提供文件URL处理、临时文件迁移和富文本编辑器内容处理:
|
||||
- get_file_url: 拼接文件域名生成完整URL
|
||||
- save_file: 将临时目录文件迁移到正式存储目录
|
||||
- save_content: 处理富文本HTML内容,提取并保存其中的图片文件
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import html
|
||||
|
||||
from config import FLASK_FILE_URL, FLASK_UPLOAD_DIR, FLASK_TEMP_PATH
|
||||
from src.common.utils.file import mkdir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_file_url(path):
|
||||
"""
|
||||
获取完整的文件访问URL
|
||||
|
||||
Args:
|
||||
path: 文件的相对路径,可以是空字符串、None或有效路径
|
||||
|
||||
Returns:
|
||||
完整的文件访问URL,如果path为空则返回None
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
return FLASK_FILE_URL + path
|
||||
|
||||
|
||||
def save_file(url, directory):
|
||||
"""
|
||||
保存文件到指定目录
|
||||
|
||||
处理本站临时文件迁移:将临时目录中的文件移动到正式存储目录。
|
||||
|
||||
Args:
|
||||
url: 文件的完整URL或相对路径
|
||||
directory: 目标子目录名称,用于分类存储
|
||||
|
||||
Returns:
|
||||
文件的相对路径,失败时返回空字符串
|
||||
"""
|
||||
if not url:
|
||||
return "文件地址不能为空"
|
||||
|
||||
if url.find(FLASK_FILE_URL) != -1:
|
||||
if url.find("/temp") != -1:
|
||||
path = FLASK_UPLOAD_DIR + "/" + directory + "/" + time.strftime('%Y%m%d')
|
||||
mkdir(path)
|
||||
|
||||
old_path = url.replace(FLASK_FILE_URL, FLASK_UPLOAD_DIR)
|
||||
new_path = FLASK_UPLOAD_DIR + "/" + directory + old_path.replace(FLASK_TEMP_PATH, "")
|
||||
|
||||
os.rename(old_path, new_path)
|
||||
return new_path.replace(FLASK_UPLOAD_DIR, "")
|
||||
else:
|
||||
return url.replace(FLASK_FILE_URL, "")
|
||||
else:
|
||||
logger.warning(f"远程文件暂不支持保存: {url}")
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def save_content(content, title, directory):
|
||||
"""
|
||||
保存富文本编辑器内容
|
||||
|
||||
处理富文本HTML内容,提取其中的图片文件并保存,
|
||||
同时将文件alt属性设置为文章标题(已做HTML转义防XSS)。
|
||||
|
||||
Args:
|
||||
content: 富文本HTML内容
|
||||
title: 文章标题,用于设置文件alt属性
|
||||
directory: 文件存储的子目录名称
|
||||
|
||||
Returns:
|
||||
处理后的富文本内容,文件地址已被替换
|
||||
"""
|
||||
image_urls = re.findall('img src="(.*?)"', content, re.S)
|
||||
|
||||
for url in image_urls:
|
||||
image = save_file(url, directory)
|
||||
if image:
|
||||
content = content.replace(url, "[IMG_URL]" + image)
|
||||
|
||||
title = html.escape(str(title) if title else "", quote=True)
|
||||
if "alt=\"\"" in content and title:
|
||||
content = content.replace("alt=\"\"", "alt=\"" + title + "\"")
|
||||
|
||||
return content
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
User-Agent解析工具模块
|
||||
=============================================================
|
||||
|
||||
提供HTTP请求User-Agent的解析功能:
|
||||
- parse_user_agent: 解析操作系统和浏览器信息
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def parse_user_agent():
|
||||
"""
|
||||
解析User-Agent,获取操作系统和浏览器信息
|
||||
|
||||
通过正则匹配User-Agent字符串,识别客户端操作系统和浏览器类型。
|
||||
支持 Windows、macOS、Linux、Android、iOS 及主流浏览器识别。
|
||||
|
||||
Returns:
|
||||
dict: 包含os和browser的字典
|
||||
{
|
||||
'os': 'Windows 10', # 操作系统信息
|
||||
'browser': 'Chrome' # 浏览器名称
|
||||
}
|
||||
无法识别时返回 '未知'
|
||||
"""
|
||||
from flask import request
|
||||
|
||||
user_agent = request.headers.get('User-Agent', '')
|
||||
|
||||
result = {
|
||||
'os': '未知',
|
||||
'browser': '未知'
|
||||
}
|
||||
|
||||
if not user_agent:
|
||||
return result
|
||||
|
||||
ua_lower = user_agent.lower()
|
||||
|
||||
# 操作系统识别规则(按优先级从高到低排列)
|
||||
os_patterns = [
|
||||
(r'windows nt 10\.0', 'Windows 10'),
|
||||
(r'windows nt 6\.1', 'Windows 7'),
|
||||
(r'windows nt 6\.2', 'Windows 8'),
|
||||
(r'windows nt 6\.3', 'Windows 8.1'),
|
||||
(r'windows nt', 'Windows'),
|
||||
(r'mac os x', 'macOS'),
|
||||
(r'linux', 'Linux'),
|
||||
(r'android', 'Android'),
|
||||
(r'iphone', 'iOS'),
|
||||
(r'ipad', 'iOS'),
|
||||
]
|
||||
|
||||
for pattern, os_name in os_patterns:
|
||||
if re.search(pattern, ua_lower):
|
||||
result['os'] = os_name
|
||||
break
|
||||
|
||||
# 浏览器识别规则(按优先级从高到低排列)
|
||||
browser_patterns = [
|
||||
(r'edg', 'Edge'), # Edge 浏览器(基于Chromium)
|
||||
(r'chrome', 'Chrome'), # Chrome 浏览器
|
||||
(r'safari', 'Safari'), # Safari 浏览器
|
||||
(r'firefox', 'Firefox'), # Firefox 浏览器
|
||||
(r'msie', 'IE'), # Internet Explorer
|
||||
(r'trident', 'IE'), # IE 11(Trident内核)
|
||||
]
|
||||
|
||||
for pattern, browser_name in browser_patterns:
|
||||
if re.search(pattern, ua_lower):
|
||||
result['browser'] = browser_name
|
||||
break
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
core 基础设施层
|
||||
=============================================================
|
||||
|
||||
提供与应用业务无关的基础设施:
|
||||
- database.py SQLAlchemy 实例
|
||||
- response.py 统一响应 R
|
||||
- jwt.py / password.py / security.py 认证与安全
|
||||
- redis_client.py Redis 客户端封装
|
||||
- mail.py / scheduler.py 扩展实例
|
||||
- exceptions.py / middleware.py / logger.py / context.py 后续阶段建设
|
||||
|
||||
依赖约束:core 层不得 import modules 层。
|
||||
"""
|
||||
@@ -0,0 +1,87 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 一米阳光团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
"""
|
||||
SQLAlchemy 数据库扩展模块
|
||||
创建数据库实例,供整个应用使用
|
||||
"""
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
# ============================================================
|
||||
# 创建 SQLAlchemy 数据库实例
|
||||
# ============================================================
|
||||
# 该实例需要在应用工厂中通过 db.init_app(app) 完成初始化
|
||||
# 使用延迟绑定模式,支持循环导入
|
||||
# ============================================================
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
def commit_independently(obj):
|
||||
"""
|
||||
使用独立session提交对象,不受请求事务影响
|
||||
|
||||
日志等审计数据无论业务成功或失败都应持久化,
|
||||
因此需要绕过 AOP 事务拦截器的 commit/rollback 机制。
|
||||
|
||||
实现方式:基于现有引擎创建独立 session,将对象添加并提交,
|
||||
完成后关闭 session,不影响请求主 session。
|
||||
|
||||
Args:
|
||||
obj: SQLAlchemy 模型实例(未添加到任何 session)
|
||||
|
||||
Raises:
|
||||
Exception: 数据库操作异常
|
||||
"""
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# 复用 Flask-SQLAlchemy 已创建的引擎,避免额外连接池
|
||||
independent_session = sessionmaker(bind=db.engine)()
|
||||
|
||||
try:
|
||||
independent_session.add(obj)
|
||||
independent_session.commit()
|
||||
except Exception:
|
||||
independent_session.rollback()
|
||||
raise
|
||||
finally:
|
||||
independent_session.close()
|
||||
+634
@@ -0,0 +1,634 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 珊瑚云团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 珊瑚云团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
"""
|
||||
JWT令牌处理模块
|
||||
=============================================================
|
||||
|
||||
该模块提供基于JWT(JSON Web Token)的令牌生成和验证功能。
|
||||
主要用于用户登录认证,生成访问令牌,并在后续请求中验证令牌的有效性。
|
||||
支持令牌过期机制,确保系统的安全性。
|
||||
|
||||
主要功能:
|
||||
1. JWT令牌生成(create_token)- 用于用户登录成功后生成访问令牌
|
||||
2. JWT令牌解析验证(parse_payload)- 验证请求中的令牌有效性
|
||||
3. 统一的异常处理,返回标准格式的验证结果
|
||||
4. 可配置的令牌过期时间
|
||||
|
||||
所属包: utils
|
||||
所在文件: utils/jwts.py
|
||||
依赖模块: jwt - JWT处理第三方库
|
||||
datetime - 时间处理模块
|
||||
os - 环境变量读取模块
|
||||
secrets - 安全随机数生成模块
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
import jwt
|
||||
from flask import request
|
||||
from jwt import exceptions
|
||||
|
||||
from config import JWT_SALT, JWT_EXPIRE_MINUTES
|
||||
|
||||
# 配置日志记录器
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================
|
||||
# JWT密钥配置
|
||||
# =============================================================
|
||||
# 从 config.auth 统一读取,密钥配置在 .env 文件中管理
|
||||
|
||||
def _validate_key_strength(key: str) -> None:
|
||||
"""
|
||||
启动时校验 JWT 密钥强度
|
||||
|
||||
校验规则:
|
||||
1. 密钥不能为空
|
||||
2. 密钥长度至少 32 字节(256 位)
|
||||
|
||||
校验失败直接抛出 RuntimeError,拒绝启动应用。
|
||||
"""
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
"JWT_SALT 未配置!请在 .env 中设置至少32字节的强随机密钥。\n"
|
||||
"生成方式:python -c \"import secrets; print(secrets.token_urlsafe(48))\""
|
||||
)
|
||||
|
||||
key_bytes = len(key.encode('utf-8'))
|
||||
if key_bytes < 32:
|
||||
raise RuntimeError(
|
||||
f"JWT_SALT 长度不足:{key_bytes} 字节,至少需要 32 字节(256位)。\n"
|
||||
"生成方式:python -c \"import secrets; print(secrets.token_urlsafe(48))\""
|
||||
)
|
||||
|
||||
logger.info(f"JWT密钥校验通过,长度:{key_bytes}字节")
|
||||
|
||||
|
||||
# 启动时校验密钥强度
|
||||
_validate_key_strength(JWT_SALT)
|
||||
|
||||
# JWT算法配置
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
|
||||
# 默认令牌过期时间(分钟),从配置读取
|
||||
DEFAULT_TIMEOUT_MINUTES = JWT_EXPIRE_MINUTES
|
||||
|
||||
|
||||
# =============================================================
|
||||
# JWT核心功能函数
|
||||
# =============================================================
|
||||
|
||||
def create_token(payload, timeout=None):
|
||||
"""
|
||||
生成JWT令牌
|
||||
=============================================================
|
||||
|
||||
根据传入的载荷数据生成JWT访问令牌,自动添加过期时间。
|
||||
使用强密钥确保令牌安全性,符合RFC 7518安全规范。
|
||||
|
||||
参数:
|
||||
payload (dict): JWT载荷数据,通常包含用户ID、用户名等信息
|
||||
timeout (int): 令牌过期时间(分钟),默认使用DEFAULT_TIMEOUT_MINUTES
|
||||
|
||||
返回:
|
||||
str: 生成的JWT令牌字符串
|
||||
|
||||
使用示例:
|
||||
>>> payload = {'userId': 1001, 'username': 'admin'}
|
||||
>>> token = create_token(payload, timeout=30)
|
||||
>>> print(token)
|
||||
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
|
||||
|
||||
注意事项:
|
||||
1. 密钥长度必须至少32字节,否则会发出警告
|
||||
2. 生产环境建议通过环境变量设置JWT_SALT
|
||||
3. 令牌过期后需要重新登录获取新令牌
|
||||
"""
|
||||
try:
|
||||
# 设置过期时间
|
||||
# 说明:在载荷中添加exp字段,指定令牌的过期时间点
|
||||
# 计算方式:当前UTC时间 + 指定的分钟数
|
||||
if timeout is None:
|
||||
timeout = DEFAULT_TIMEOUT_MINUTES
|
||||
|
||||
# 创建payload副本,避免修改原始数据
|
||||
payload_copy = payload.copy()
|
||||
|
||||
# 添加过期时间(exp)和签发时间(iat)
|
||||
# exp: 过期时间,超过此时间令牌无效
|
||||
# iat: 签发时间,用于令牌生命周期计算
|
||||
current_time = datetime.datetime.now(tz=datetime.timezone.utc)
|
||||
payload_copy['exp'] = current_time + datetime.timedelta(minutes=timeout)
|
||||
payload_copy['iat'] = current_time
|
||||
|
||||
# 声明类型和加密算法
|
||||
# 说明:定义JWT头部信息,包含令牌类型和加密算法
|
||||
headers = {
|
||||
"typ": "JWT", # 令牌类型
|
||||
"alg": JWT_ALGORITHM # 加密算法
|
||||
}
|
||||
|
||||
# 生成JWT令牌
|
||||
# 函数:jwt.encode()
|
||||
# 参数说明:
|
||||
# payload: JWT载荷数据
|
||||
# key: 加密密钥(盐)
|
||||
# algorithm: 加密算法(HS256)
|
||||
# headers: JWT头部信息
|
||||
token = jwt.encode(
|
||||
payload=payload_copy,
|
||||
key=JWT_SALT,
|
||||
algorithm=JWT_ALGORITHM,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
# 记录日志(可选,生产环境建议使用DEBUG级别)
|
||||
logger.debug(f"生成JWT令牌成功,过期时间:{timeout}分钟")
|
||||
|
||||
return token
|
||||
|
||||
except Exception as e:
|
||||
# 捕获并记录生成令牌时的异常
|
||||
logger.error(f"生成JWT令牌失败: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
def parse_payload(token):
|
||||
"""
|
||||
解析验证JWT令牌
|
||||
=============================================================
|
||||
|
||||
验证传入的JWT令牌,检查签名有效性和过期状态。
|
||||
统一处理各种异常情况,返回标准格式的结果对象。
|
||||
|
||||
参数:
|
||||
token (str): 需要验证的JWT令牌字符串
|
||||
|
||||
返回:
|
||||
dict: 验证结果,包含以下字段:
|
||||
code (int): 状态码,0-成功,-1-失败
|
||||
data (dict/None): 验证成功时返回载荷数据,失败时为None
|
||||
msg (str): 提示信息
|
||||
|
||||
使用示例:
|
||||
>>> result = parse_payload(token)
|
||||
>>> if result['code'] == 0:
|
||||
... user_id = result['data']['userId']
|
||||
... username = result['data']['username']
|
||||
... else:
|
||||
... error_msg = result['msg']
|
||||
... # 处理令牌失效,跳转登录页
|
||||
|
||||
异常处理说明:
|
||||
- ExpiredSignatureError: 令牌已过期,需要重新登录
|
||||
- DecodeError: 解码失败,令牌格式错误或签名无效
|
||||
- InvalidTokenError: 其他JWT相关异常
|
||||
"""
|
||||
# 初始化返回结果
|
||||
# 说明:定义统一返回格式,默认成功状态
|
||||
result = {
|
||||
"code": 0,
|
||||
"data": None,
|
||||
"msg": "操作成功"
|
||||
}
|
||||
|
||||
# 参数校验
|
||||
if not token:
|
||||
result['code'] = -1
|
||||
result['msg'] = "token不能为空"
|
||||
return result
|
||||
|
||||
try:
|
||||
# 解密验证JWT令牌
|
||||
# 函数:jwt.decode()
|
||||
# 参数说明:
|
||||
# jwt: 令牌字符串
|
||||
# key: 解密密钥(必须与加密时使用的密钥一致)
|
||||
# algorithms: 允许的算法列表
|
||||
#
|
||||
# 说明:jwt.decode会自动完成以下验证:
|
||||
# 1. 签名验证 - 确保令牌未被篡改
|
||||
# 2. 过期时间验证 - 检查exp字段是否大于当前时间
|
||||
# 3. 算法验证 - 确保使用正确的加密算法
|
||||
verified_payload = jwt.decode(
|
||||
token,
|
||||
JWT_SALT,
|
||||
algorithms=[JWT_ALGORITHM]
|
||||
)
|
||||
|
||||
# 验证成功,将载荷数据存入返回结果
|
||||
# 注意:返回的数据中不包含敏感信息(如密码等)
|
||||
result['data'] = verified_payload
|
||||
|
||||
# 可选:记录验证成功的日志(生产环境建议使用DEBUG级别)
|
||||
logger.debug(f"JWT令牌验证成功,用户: {verified_payload.get('username', 'unknown')}")
|
||||
|
||||
except exceptions.ExpiredSignatureError as e:
|
||||
# 令牌已过期异常处理
|
||||
# 说明:令牌超过设置的exp时间,需要重新登录获取新令牌
|
||||
result['code'] = -1
|
||||
result['msg'] = "token已失效,请重新登录"
|
||||
logger.info(f"JWT令牌已过期: {str(e)}")
|
||||
|
||||
except exceptions.DecodeError as e:
|
||||
# 令牌解码失败异常处理
|
||||
# 说明:令牌格式错误或签名验证失败,可能被篡改
|
||||
result['code'] = -1
|
||||
result['msg'] = "token认证失败,无效的令牌格式"
|
||||
logger.warning(f"JWT令牌解码失败: {str(e)}")
|
||||
|
||||
except exceptions.InvalidTokenError as e:
|
||||
# 无效令牌异常处理
|
||||
# 说明:捕获其他所有JWT相关异常,如算法不匹配等
|
||||
result['code'] = -1
|
||||
result['msg'] = "非法的token,请检查令牌有效性"
|
||||
logger.warning(f"无效的JWT令牌: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
# 其他未预期的异常处理
|
||||
result['code'] = -1
|
||||
result['msg'] = "token验证失败,系统异常"
|
||||
logger.error(f"JWT验证系统异常: {str(e)}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def refresh_token(token, extend_minutes=None):
|
||||
"""
|
||||
刷新JWT令牌
|
||||
=============================================================
|
||||
|
||||
验证现有令牌有效性,如果有效则生成新的令牌,延长有效期。
|
||||
|
||||
参数:
|
||||
token (str): 需要刷新的JWT令牌字符串
|
||||
extend_minutes (int): 延长的分钟数,默认使用DEFAULT_TIMEOUT_MINUTES
|
||||
|
||||
返回:
|
||||
dict: 刷新结果,包含以下字段:
|
||||
code (int): 状态码,0-成功,-1-失败
|
||||
data (dict/None): 成功时包含新令牌,失败时为None
|
||||
msg (str): 提示信息
|
||||
|
||||
使用示例:
|
||||
>>> result = refresh_token(old_token, extend_minutes=30)
|
||||
>>> if result['code'] == 0:
|
||||
... new_token = result['data']['access_token']
|
||||
... else:
|
||||
... # 令牌无效,需要重新登录
|
||||
... error_msg = result['msg']
|
||||
|
||||
注意事项:
|
||||
1. 令牌只有在有效期内才能刷新
|
||||
2. 刷新后会生成全新的令牌,旧令牌立即失效
|
||||
3. 建议在令牌剩余有效期少于一半时进行刷新
|
||||
"""
|
||||
if extend_minutes is None:
|
||||
extend_minutes = DEFAULT_TIMEOUT_MINUTES
|
||||
|
||||
# 先验证原令牌
|
||||
result = parse_payload(token)
|
||||
|
||||
# 如果验证失败,返回错误信息
|
||||
if result['code'] != 0:
|
||||
return {
|
||||
"code": -1,
|
||||
"data": None,
|
||||
"msg": f"令牌无效,无法刷新: {result['msg']}"
|
||||
}
|
||||
|
||||
try:
|
||||
# 获取原载荷数据(不包含exp、iat等JWT标准字段)
|
||||
payload = result['data']
|
||||
|
||||
# 移除JWT标准字段,避免重复添加
|
||||
# 注意:保留原始业务数据,移除系统字段
|
||||
clean_payload = {k: v for k, v in payload.items()
|
||||
if k not in ['exp', 'iat', 'nbf']}
|
||||
|
||||
# 生成新令牌
|
||||
new_token = create_token(clean_payload, timeout=extend_minutes)
|
||||
|
||||
logger.info(f"JWT令牌刷新成功,延长{extend_minutes}分钟")
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"access_token": new_token,
|
||||
"expires_in": extend_minutes * 60 # 转换为秒
|
||||
},
|
||||
"msg": "令牌刷新成功"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"刷新JWT令牌失败: {str(e)}")
|
||||
return {
|
||||
"code": -1,
|
||||
"data": None,
|
||||
"msg": "令牌刷新失败,请重新登录"
|
||||
}
|
||||
|
||||
|
||||
def get_token_expiration(token):
|
||||
"""
|
||||
获取JWT令牌的过期时间
|
||||
|
||||
验证签名后读取过期时间,确保token未被篡改。
|
||||
不过验证过期状态(verify_exp=False),以便调用方
|
||||
读取已过期token的过期时间用于剩余时间计算等场景。
|
||||
|
||||
Args:
|
||||
token (str): JWT令牌字符串
|
||||
|
||||
Returns:
|
||||
datetime/None: 令牌过期时间(UTC),解析失败返回None
|
||||
"""
|
||||
try:
|
||||
# 验证签名确保token未被篡改,但不验证过期状态
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
JWT_SALT,
|
||||
algorithms=[JWT_ALGORITHM],
|
||||
options={"verify_exp": False}
|
||||
)
|
||||
|
||||
exp_timestamp = payload.get('exp')
|
||||
if exp_timestamp:
|
||||
return datetime.datetime.fromtimestamp(exp_timestamp, tz=datetime.timezone.utc)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"获取JWT过期时间失败: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def get_access_token():
|
||||
"""
|
||||
从请求头中提取 access_token
|
||||
=============================================================
|
||||
|
||||
从 HTTP 请求的 Authorization 头中提取 JWT token,返回纯净的 token 字符串。
|
||||
统一处理 token 提取逻辑,便于在其他模块中复用。
|
||||
|
||||
参数:
|
||||
request: Django 请求对象
|
||||
|
||||
返回:
|
||||
tuple: (success, token, message)
|
||||
- success (bool): 是否成功获取 token
|
||||
- token (str/None): token 字符串,失败时为 None
|
||||
- message (str): 提示信息
|
||||
|
||||
使用示例:
|
||||
>>> success, token, msg = get_access_token()
|
||||
>>> if success:
|
||||
... # 使用 token 进行后续处理
|
||||
... parse_result = parse_payload(token)
|
||||
... else:
|
||||
... # 处理错误
|
||||
... return R.failed(msg)
|
||||
"""
|
||||
try:
|
||||
# 从请求头中获取 token 值
|
||||
# 说明:Authorization 头格式通常为 "Bearer <token>"
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header:
|
||||
return False, None, '缺少 Authorization 头'
|
||||
|
||||
# 处理 Bearer 前缀
|
||||
# 说明:移除 "Bearer " 前缀并去除首尾空格,获取纯净 token
|
||||
access_token = auth_header.replace('Bearer ', "").strip()
|
||||
if not access_token:
|
||||
return False, None, 'token 为空'
|
||||
|
||||
return True, access_token, 'success'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取 access_token 异常: {str(e)}")
|
||||
return False, None, f'token 提取异常: {str(e)}'
|
||||
|
||||
|
||||
# token解析工具类
|
||||
def parse_token(request):
|
||||
"""
|
||||
解析请求头中的token,返回解析结果
|
||||
=============================================================
|
||||
|
||||
从HTTP请求的Authorization头中提取JWT token,进行解析验证,
|
||||
并返回包含用户信息的结构化数据。统一处理各种异常情况。
|
||||
|
||||
参数:
|
||||
request: Django请求对象
|
||||
|
||||
返回:
|
||||
dict: 包含解析结果和用户信息的字典
|
||||
{
|
||||
'code': 0, # 0-成功,-1-失败
|
||||
'msg': 'success', # 提示信息
|
||||
'data': {
|
||||
'userId': 0, # 用户ID
|
||||
'username': '', # 用户名
|
||||
'realname': '' # 真实姓名
|
||||
}
|
||||
}
|
||||
|
||||
使用示例:
|
||||
>>> result = parse_token(request)
|
||||
>>> if result['code'] == 0:
|
||||
... user_id = result['data']['userId']
|
||||
... username = result['data']['username']
|
||||
"""
|
||||
# 初始化返回结果
|
||||
# 说明:设置默认失败状态,确保返回结构完整性
|
||||
result = {
|
||||
'code': -1,
|
||||
'msg': 'token无效',
|
||||
'data': {
|
||||
'userId': 0,
|
||||
'userName': None
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
# 获取 access_token
|
||||
success, access_token, msg = get_access_token()
|
||||
if not success:
|
||||
result['msg'] = msg
|
||||
return result
|
||||
|
||||
# JWT解密
|
||||
# 函数:parse_payload(access_token)
|
||||
# 说明:调用JWT解析工具验证token有效性
|
||||
parse_result = parse_payload(access_token)
|
||||
|
||||
# 验证解析结果
|
||||
# 说明:确保解析结果格式正确
|
||||
if not isinstance(parse_result, dict):
|
||||
result['msg'] = '解析结果格式错误'
|
||||
return result
|
||||
|
||||
# 结果标识
|
||||
# 说明:code为0表示验证通过
|
||||
code = parse_result.get('code', -1)
|
||||
if code != 0:
|
||||
result['msg'] = parse_result.get('msg', 'token验证失败')
|
||||
return result
|
||||
|
||||
# 获取数据部分
|
||||
# 说明:提取token载荷中的用户数据
|
||||
data = parse_result.get('data', {})
|
||||
if not isinstance(data, dict):
|
||||
result['msg'] = '数据格式错误'
|
||||
return result
|
||||
|
||||
# 提取用户信息
|
||||
# 说明:从载荷中获取具体用户字段
|
||||
user_id = data.get('userId', 0)
|
||||
username = data.get('username', '')
|
||||
realname = data.get('realname', '')
|
||||
|
||||
# 更新返回结果
|
||||
# 说明:设置成功状态并填充用户信息
|
||||
result['code'] = 0
|
||||
result['msg'] = 'success'
|
||||
result['data']['userId'] = user_id
|
||||
result['data']['username'] = username
|
||||
result['data']['realname'] = realname
|
||||
|
||||
except Exception as e:
|
||||
# 异常捕获
|
||||
# 说明:防止未处理的异常导致系统崩溃
|
||||
result['msg'] = f'token解析异常: {str(e)}'
|
||||
# 可以在这里记录日志
|
||||
# logger.error(f"token解析异常: {str(e)}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================
|
||||
# Token黑名单功能
|
||||
# =============================================================
|
||||
|
||||
# 黑名单Key前缀,用于Redis中存储被注销的Token
|
||||
_TOKEN_BLACKLIST_PREFIX = 'token:blacklist:'
|
||||
|
||||
|
||||
def _token_fingerprint(token: str) -> str:
|
||||
"""
|
||||
生成Token指纹(SHA256摘要)
|
||||
|
||||
避免将完整Token存入Redis,仅存储摘要值,减少泄露风险。
|
||||
|
||||
Args:
|
||||
token: JWT令牌字符串
|
||||
|
||||
Returns:
|
||||
str: SHA256十六进制摘要
|
||||
"""
|
||||
return hashlib.sha256(token.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def add_token_to_blacklist(token: str) -> bool:
|
||||
"""
|
||||
将Token加入黑名单
|
||||
|
||||
退出登录时调用,使Token立即失效。黑名单条目的TTL与Token剩余有效期一致,
|
||||
Token自然过期后黑名单条目自动清除,避免Redis内存无限增长。
|
||||
|
||||
Args:
|
||||
token: JWT令牌字符串
|
||||
|
||||
Returns:
|
||||
bool: 是否成功加入黑名单
|
||||
"""
|
||||
try:
|
||||
from src.core.redis_client import redis
|
||||
|
||||
# 获取Token剩余有效期
|
||||
expiration = get_token_expiration(token)
|
||||
if expiration is None:
|
||||
# 无法解析过期时间,使用默认过期时间作为TTL
|
||||
ttl_seconds = DEFAULT_TIMEOUT_MINUTES * 60
|
||||
else:
|
||||
ttl_seconds = int((expiration - datetime.datetime.now(tz=datetime.timezone.utc)).total_seconds())
|
||||
if ttl_seconds <= 0:
|
||||
# Token已自然过期,无需加入黑名单
|
||||
return True
|
||||
|
||||
fingerprint = _token_fingerprint(token)
|
||||
key = f'{_TOKEN_BLACKLIST_PREFIX}{fingerprint}'
|
||||
redis.set(key, '1', expire=ttl_seconds)
|
||||
|
||||
logger.info(f"Token已加入黑名单,TTL={ttl_seconds}秒")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token加入黑名单失败: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def is_token_blacklisted(token: str) -> bool:
|
||||
"""
|
||||
检查Token是否在黑名单中
|
||||
|
||||
在登录验证中间件中调用,拒绝已被注销的Token。
|
||||
|
||||
Args:
|
||||
token: JWT令牌字符串
|
||||
|
||||
Returns:
|
||||
bool: True-已被注销(在黑名单中),False-有效
|
||||
"""
|
||||
try:
|
||||
from src.core.redis_client import redis
|
||||
|
||||
fingerprint = _token_fingerprint(token)
|
||||
key = f'{_TOKEN_BLACKLIST_PREFIX}{fingerprint}'
|
||||
return redis.exists(key) > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token黑名单检查异常: {str(e)}")
|
||||
# Redis不可用时放行,避免阻断所有请求
|
||||
return False
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Flask-Mail 扩展初始化模块
|
||||
=============================================================
|
||||
|
||||
仅负责 Flask-Mail 扩展实例的创建与初始化。
|
||||
|
||||
"""
|
||||
|
||||
from flask import Flask
|
||||
from flask_mail import Mail
|
||||
|
||||
# Flask-Mail 扩展实例
|
||||
# 在应用工厂中通过 mail.init_app(app) 完成绑定
|
||||
mail = Mail()
|
||||
|
||||
|
||||
def init_mail(app: Flask):
|
||||
"""
|
||||
初始化 Flask-Mail 扩展,将 Mail 实例绑定到应用。
|
||||
|
||||
Args:
|
||||
app: Flask 应用实例
|
||||
|
||||
Example:
|
||||
# 在应用工厂函数中调用
|
||||
init_mail(app)
|
||||
"""
|
||||
from config import (
|
||||
MAIL_MAILER, MAIL_SERVER, MAIL_PORT, MAIL_USE_SSL,
|
||||
MAIL_USERNAME, MAIL_PASSWORD, MAIL_ENCRYPTION,
|
||||
MAIL_FROM_NAME, MAIL_FROM_ADDRESS,
|
||||
)
|
||||
|
||||
app.config['MAIL_MAILER'] = MAIL_MAILER
|
||||
app.config['MAIL_SERVER'] = MAIL_SERVER
|
||||
app.config['MAIL_PORT'] = MAIL_PORT
|
||||
app.config['MAIL_USE_SSL'] = MAIL_USE_SSL
|
||||
app.config['MAIL_USERNAME'] = MAIL_USERNAME
|
||||
app.config['MAIL_PASSWORD'] = MAIL_PASSWORD
|
||||
app.config['MAIL_ENCRYPTION'] = MAIL_ENCRYPTION
|
||||
app.config['MAIL_DEFAULT_SENDER'] = (
|
||||
f'{MAIL_FROM_NAME} <{MAIL_FROM_ADDRESS}>' if MAIL_FROM_NAME and MAIL_FROM_ADDRESS
|
||||
else MAIL_FROM_ADDRESS or None
|
||||
)
|
||||
|
||||
mail.init_app(app)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
密码加密工具模块
|
||||
=============================================================
|
||||
|
||||
该模块提供基于 bcrypt 的密码加密和验证功能。
|
||||
采用 bcrypt(password + salt) 方案,bcrypt 内置盐与外部 salt 双重加盐,
|
||||
确保相同密码产生不同哈希,有效抵御彩虹表攻击。
|
||||
|
||||
主要功能:
|
||||
1. 生成随机盐值(generate_salt)
|
||||
2. 密码加密(encrypt_password)- bcrypt(password + salt)
|
||||
3. 密码验证(verify_password)
|
||||
|
||||
所属包: utils
|
||||
所在文件: utils/password.py
|
||||
依赖模块: bcrypt - 密码哈希第三方库
|
||||
secrets - 安全随机数生成模块
|
||||
"""
|
||||
|
||||
import secrets
|
||||
|
||||
import bcrypt
|
||||
|
||||
# bcrypt 工作因子,值越大计算越慢越安全,默认12(约250ms)
|
||||
BCRYPT_ROUNDS = 12
|
||||
|
||||
|
||||
def generate_salt(length: int = 10) -> str:
|
||||
"""
|
||||
生成随机盐值
|
||||
|
||||
Args:
|
||||
length: 盐值长度,默认10位
|
||||
|
||||
Returns:
|
||||
str: 随机盐值字符串
|
||||
"""
|
||||
return secrets.token_urlsafe(length)[:length]
|
||||
|
||||
|
||||
def encrypt_password(password: str, salt: str) -> str:
|
||||
"""
|
||||
使用 bcrypt 加密密码
|
||||
|
||||
将密码与外部 salt 拼接后进行 bcrypt 哈希,实现双重加盐。
|
||||
|
||||
Args:
|
||||
password: 明文密码
|
||||
salt: 外部盐值
|
||||
|
||||
Returns:
|
||||
str: bcrypt 哈希字符串(60字符)
|
||||
"""
|
||||
combined = (password + salt).encode('utf-8')
|
||||
hashed = bcrypt.hashpw(combined, bcrypt.gensalt(rounds=BCRYPT_ROUNDS))
|
||||
return hashed.decode('utf-8')
|
||||
|
||||
|
||||
def verify_password(password: str, salt: str, hashed: str) -> bool:
|
||||
"""
|
||||
验证密码是否正确
|
||||
|
||||
Args:
|
||||
password: 待验证的明文密码
|
||||
salt: 外部盐值
|
||||
hashed: 存储的 bcrypt 哈希值
|
||||
|
||||
Returns:
|
||||
bool: 密码是否匹配
|
||||
"""
|
||||
combined = (password + salt).encode('utf-8')
|
||||
return bcrypt.checkpw(combined, hashed.encode('utf-8'))
|
||||
@@ -0,0 +1,377 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 一米阳光团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
"""
|
||||
Redis 客户端封装模块
|
||||
=============================================================
|
||||
|
||||
整合来源:
|
||||
- extensions/redis.py:FlaskRedis 扩展实例与初始化
|
||||
- utils/redis_client.py:RedisClient 高级封装(序列化、类型转换)
|
||||
|
||||
提供 Redis 操作的统一封装,支持字符串、哈希、列表、集合、有序集合等
|
||||
数据类型。自动处理序列化/反序列化,业务代码无需关心底层存储格式。
|
||||
|
||||
使用方式:
|
||||
from src.core.redis_client import redis
|
||||
|
||||
redis.set('key', 'value', expire=3600)
|
||||
value = redis.get('key')
|
||||
"""
|
||||
|
||||
import json
|
||||
import pickle
|
||||
from typing import Any, Union, List, Dict, Tuple
|
||||
|
||||
from flask import Flask
|
||||
from flask_redis import FlaskRedis
|
||||
|
||||
# ============================================================
|
||||
# FlaskRedis 扩展实例
|
||||
# ============================================================
|
||||
# 在应用工厂中通过 init_redis(app) 完成绑定
|
||||
redis_client = FlaskRedis(decode_responses=True, protocol=2)
|
||||
|
||||
|
||||
def init_redis(app: Flask):
|
||||
"""
|
||||
初始化 Redis 扩展,将 FlaskRedis 实例绑定到应用。
|
||||
|
||||
Args:
|
||||
app: Flask 应用实例
|
||||
|
||||
Example:
|
||||
# 在应用工厂函数中调用
|
||||
init_redis(app)
|
||||
"""
|
||||
redis_client.init_app(app)
|
||||
|
||||
|
||||
class RedisClient:
|
||||
"""
|
||||
Redis客户端封装类
|
||||
|
||||
提供丰富的Redis操作方法,支持字符串、哈希、列表、集合、有序集合等数据类型
|
||||
"""
|
||||
|
||||
def __init__(self, client=None):
|
||||
"""
|
||||
初始化Redis客户端
|
||||
|
||||
Args:
|
||||
client: FlaskRedis实例,如果为None则使用全局redis_client
|
||||
"""
|
||||
self.client = client or redis_client
|
||||
|
||||
# ==================== 基础方法 ====================
|
||||
|
||||
def set(self, key: str, value: Any, expire: int = None, nx: bool = False, xx: bool = False) -> bool:
|
||||
"""设置键值对"""
|
||||
serialized_value = self._serialize(value)
|
||||
|
||||
if nx and xx:
|
||||
raise ValueError("nx和xx不能同时为True")
|
||||
|
||||
if nx:
|
||||
result = self.client.setnx(key, serialized_value)
|
||||
if expire and result:
|
||||
self.client.expire(key, expire)
|
||||
return result
|
||||
elif xx:
|
||||
result = self.client.set(key, serialized_value, xx=True)
|
||||
if expire and result:
|
||||
self.client.expire(key, expire)
|
||||
return result
|
||||
else:
|
||||
if expire:
|
||||
return self.client.setex(key, expire, serialized_value)
|
||||
else:
|
||||
return self.client.set(key, serialized_value)
|
||||
|
||||
def get(self, key: str, default: Any = None, deserialize: bool = True) -> Any:
|
||||
"""获取键值"""
|
||||
data = self.client.get(key)
|
||||
if data is None:
|
||||
return default
|
||||
|
||||
if deserialize:
|
||||
return self._deserialize(data)
|
||||
return data
|
||||
|
||||
def delete(self, *keys: str) -> int:
|
||||
"""删除一个或多个键"""
|
||||
if not keys:
|
||||
return 0
|
||||
return self.client.delete(*keys)
|
||||
|
||||
def exists(self, *keys: str) -> int:
|
||||
"""检查键是否存在"""
|
||||
if not keys:
|
||||
return 0
|
||||
return self.client.exists(*keys)
|
||||
|
||||
def expire(self, key: str, seconds: int) -> bool:
|
||||
"""设置键的过期时间"""
|
||||
return self.client.expire(key, seconds)
|
||||
|
||||
def expireat(self, key: str, timestamp: int) -> bool:
|
||||
"""设置键在指定时间戳过期"""
|
||||
return self.client.expireat(key, timestamp)
|
||||
|
||||
def ttl(self, key: str) -> int:
|
||||
"""获取键的剩余过期时间"""
|
||||
return self.client.ttl(key)
|
||||
|
||||
def keys(self, pattern: str = '*') -> List[str]:
|
||||
"""获取匹配模式的所有键(使用SCAN,避免KEYS阻塞)"""
|
||||
result = []
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, batch = self.client.scan(cursor=cursor, match=pattern, count=100)
|
||||
result.extend(batch)
|
||||
if cursor == 0:
|
||||
break
|
||||
return result
|
||||
|
||||
# ==================== 哈希表操作 ====================
|
||||
|
||||
def hset(self, name: str, key: str, value: Any) -> int:
|
||||
"""设置哈希表中的字段值"""
|
||||
serialized_value = self._serialize(value)
|
||||
return self.client.hset(name, key, serialized_value)
|
||||
|
||||
def hget(self, name: str, key: str, deserialize: bool = True) -> Any:
|
||||
"""获取哈希表中的字段值"""
|
||||
data = self.client.hget(name, key)
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
if deserialize:
|
||||
return self._deserialize(data)
|
||||
return data
|
||||
|
||||
def hgetall(self, name: str, deserialize: bool = True) -> Dict[str, Any]:
|
||||
"""获取哈希表中所有字段和值"""
|
||||
data = self.client.hgetall(name)
|
||||
if not data:
|
||||
return {}
|
||||
|
||||
if deserialize:
|
||||
return {k.decode(): self._deserialize(v) for k, v in data.items()}
|
||||
return {k.decode(): v for k, v in data.items()}
|
||||
|
||||
def hdel(self, name: str, *keys: str) -> int:
|
||||
"""删除哈希表中的字段"""
|
||||
if not keys:
|
||||
return 0
|
||||
return self.client.hdel(name, *keys)
|
||||
|
||||
def hincrby(self, name: str, key: str, amount: int = 1) -> int:
|
||||
"""为哈希表中的字段值增加指定数值"""
|
||||
return self.client.hincrby(name, key, amount)
|
||||
|
||||
# ==================== 列表操作 ====================
|
||||
|
||||
def lpush(self, name: str, *values: Any) -> int:
|
||||
"""从列表左侧插入元素"""
|
||||
serialized_values = [self._serialize(v) for v in values]
|
||||
return self.client.lpush(name, *serialized_values)
|
||||
|
||||
def rpush(self, name: str, *values: Any) -> int:
|
||||
"""从列表右侧插入元素"""
|
||||
serialized_values = [self._serialize(v) for v in values]
|
||||
return self.client.rpush(name, *serialized_values)
|
||||
|
||||
def lpop(self, name: str, deserialize: bool = True) -> Any:
|
||||
"""从列表左侧弹出元素"""
|
||||
data = self.client.lpop(name)
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
if deserialize:
|
||||
return self._deserialize(data)
|
||||
return data
|
||||
|
||||
def rpop(self, name: str, deserialize: bool = True) -> Any:
|
||||
"""从列表右侧弹出元素"""
|
||||
data = self.client.rpop(name)
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
if deserialize:
|
||||
return self._deserialize(data)
|
||||
return data
|
||||
|
||||
def lrange(self, name: str, start: int, end: int, deserialize: bool = True) -> List[Any]:
|
||||
"""获取列表指定范围的元素"""
|
||||
data = self.client.lrange(name, start, end)
|
||||
if not data:
|
||||
return []
|
||||
|
||||
if deserialize:
|
||||
return [self._deserialize(item) for item in data]
|
||||
return [item for item in data]
|
||||
|
||||
def llen(self, name: str) -> int:
|
||||
"""获取列表长度"""
|
||||
return self.client.llen(name)
|
||||
|
||||
# ==================== 集合操作 ====================
|
||||
|
||||
def sadd(self, name: str, *values: Any) -> int:
|
||||
"""向集合中添加元素"""
|
||||
serialized_values = [self._serialize(v) for v in values]
|
||||
return self.client.sadd(name, *serialized_values)
|
||||
|
||||
def srem(self, name: str, *values: Any) -> int:
|
||||
"""从集合中移除元素"""
|
||||
serialized_values = [self._serialize(v) for v in values]
|
||||
return self.client.srem(name, *serialized_values)
|
||||
|
||||
def smembers(self, name: str, deserialize: bool = True) -> set:
|
||||
"""获取集合中所有元素"""
|
||||
data = self.client.smembers(name)
|
||||
if not data:
|
||||
return set()
|
||||
|
||||
if deserialize:
|
||||
return {self._deserialize(item) for item in data}
|
||||
return {item for item in data}
|
||||
|
||||
def sismember(self, name: str, value: Any) -> bool:
|
||||
"""判断元素是否在集合中"""
|
||||
serialized_value = self._serialize(value)
|
||||
return self.client.sismember(name, serialized_value)
|
||||
|
||||
# ==================== 有序集合操作 ====================
|
||||
|
||||
def zadd(self, name: str, mapping: Dict[Any, float]) -> int:
|
||||
"""向有序集合中添加元素"""
|
||||
serialized_mapping = {self._serialize(k): v for k, v in mapping.items()}
|
||||
return self.client.zadd(name, serialized_mapping)
|
||||
|
||||
def zrange(self, name: str, start: int, end: int, withscores: bool = False,
|
||||
deserialize: bool = True) -> Union[List[Any], List[Tuple[Any, float]]]:
|
||||
"""获取有序集合指定范围的元素(按分数升序)"""
|
||||
data = self.client.zrange(name, start, end, withscores=withscores)
|
||||
if not data:
|
||||
return []
|
||||
|
||||
if withscores:
|
||||
if deserialize:
|
||||
return [(self._deserialize(item[0]), item[1]) for item in data]
|
||||
return [(item[0], item[1]) for item in data]
|
||||
else:
|
||||
if deserialize:
|
||||
return [self._deserialize(item) for item in data]
|
||||
return list(data)
|
||||
|
||||
def zrevrange(self, name: str, start: int, end: int, withscores: bool = False,
|
||||
deserialize: bool = True) -> Union[List[Any], List[Tuple[Any, float]]]:
|
||||
"""获取有序集合指定范围的元素(按分数降序)"""
|
||||
data = self.client.zrevrange(name, start, end, withscores=withscores)
|
||||
if not data:
|
||||
return []
|
||||
|
||||
if withscores:
|
||||
if deserialize:
|
||||
return [(self._deserialize(item[0]), item[1]) for item in data]
|
||||
return [(item[0], item[1]) for item in data]
|
||||
else:
|
||||
if deserialize:
|
||||
return [self._deserialize(item) for item in data]
|
||||
return list(data)
|
||||
|
||||
# ==================== 高级操作 ====================
|
||||
|
||||
def pipeline(self):
|
||||
"""获取管道对象,用于批量执行命令"""
|
||||
return self.client.pipeline()
|
||||
|
||||
def incr(self, key: str, amount: int = 1) -> int:
|
||||
"""增加键的值(用于计数器)"""
|
||||
return self.client.incrby(key, amount)
|
||||
|
||||
def decr(self, key: str, amount: int = 1) -> int:
|
||||
"""减少键的值"""
|
||||
return self.client.decrby(key, amount)
|
||||
|
||||
def lock(self, key: str, timeout: int = 10, blocking: bool = True,
|
||||
blocking_timeout: int = None) -> Any:
|
||||
"""获取分布式锁"""
|
||||
return self.client.lock(key, timeout=timeout, blocking=blocking,
|
||||
blocking_timeout=blocking_timeout)
|
||||
|
||||
# ==================== 私有方法 ====================
|
||||
|
||||
def _serialize(self, value: Any) -> Union[str, bytes]:
|
||||
"""序列化值"""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
elif isinstance(value, (int, float, bool)):
|
||||
return str(value)
|
||||
elif isinstance(value, (dict, list, tuple)):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
else:
|
||||
return pickle.dumps(value)
|
||||
|
||||
def _deserialize(self, value: Union[str, bytes]) -> Any:
|
||||
"""反序列化值"""
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if isinstance(value, bytes):
|
||||
try:
|
||||
value = value.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
return pickle.loads(value)
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return value
|
||||
|
||||
|
||||
# 创建全局Redis客户端实例
|
||||
redis = RedisClient()
|
||||
@@ -0,0 +1,222 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 珊瑚云团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 珊瑚云团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
"""
|
||||
统一响应结果模块
|
||||
=============================================================
|
||||
|
||||
该模块提供了系统统一的JSON响应格式,确保所有API接口返回格式一致。
|
||||
包含成功响应和失败响应两种基础方法,支持扩展参数。
|
||||
|
||||
响应格式规范:
|
||||
- code: 状态码,0表示成功,1表示失败(可扩展其他状态码)
|
||||
- data: 响应数据,成功时返回具体数据,失败时为None
|
||||
- msg: 提示信息,对操作结果的文字描述
|
||||
- ok: 布尔值,标识操作是否成功(兼容旧版前端)
|
||||
- 其他扩展参数:可通过kwargs动态添加
|
||||
|
||||
使用示例:
|
||||
# 成功响应
|
||||
return R.ok(data={"id": 1, "name": "张三"})
|
||||
return R.ok(msg="操作成功")
|
||||
|
||||
# 失败响应
|
||||
return R.failed(msg="用户名或密码错误")
|
||||
return R.failed(msg="服务器内部错误", code=500)
|
||||
|
||||
# 带扩展参数的响应
|
||||
return R.ok(data=user_info, total=100, pages=10)
|
||||
"""
|
||||
|
||||
from typing import Any, Optional, Dict
|
||||
|
||||
from flask import jsonify
|
||||
|
||||
from src.common.constants.common import MESSAGE_OK, MESSAGE_FAIL
|
||||
|
||||
|
||||
def ok(
|
||||
data: Optional[Any] = None,
|
||||
msg: str = MESSAGE_OK,
|
||||
code: int = 0,
|
||||
**kwargs: Any
|
||||
) -> jsonify:
|
||||
"""
|
||||
生成成功响应
|
||||
|
||||
构建一个标准的成功响应JSON对象,包含状态码、数据、提示信息等字段。
|
||||
支持通过kwargs添加额外的响应字段,便于扩展。
|
||||
|
||||
Args:
|
||||
data: 响应数据,可以是任意类型(dict, list, str, int等),默认为None
|
||||
msg: 成功提示信息,默认使用常量MESSAGE_OK
|
||||
code: 状态码,0表示成功,默认为0
|
||||
**kwargs: 额外的键值对参数,会合并到响应体中
|
||||
|
||||
Returns:
|
||||
jsonify: 格式化的JSON响应对象
|
||||
|
||||
Examples:
|
||||
>>> return R.ok()
|
||||
{"code": 0, "data": None, "msg": "操作成功", "ok": True}
|
||||
|
||||
>>> return R.ok(data={"id": 1, "name": "张三"})
|
||||
{"code": 0, "data": {"id": 1, "name": "张三"}, "msg": "操作成功", "ok": True}
|
||||
|
||||
>>> return R.ok(msg="创建成功", total=100)
|
||||
{"code": 0, "data": None, "msg": "创建成功", "ok": True, "total": 100}
|
||||
"""
|
||||
# 构建基础响应体
|
||||
response_body: Dict[str, Any] = {
|
||||
"code": code,
|
||||
"data": data,
|
||||
"msg": msg,
|
||||
"ok": True
|
||||
}
|
||||
|
||||
# 合并扩展参数
|
||||
if kwargs:
|
||||
response_body.update(kwargs)
|
||||
|
||||
# 返回JSON响应
|
||||
return jsonify(response_body)
|
||||
|
||||
|
||||
def failed(
|
||||
msg: str = MESSAGE_FAIL,
|
||||
code: int = 1,
|
||||
data: Optional[Any] = None,
|
||||
**kwargs: Any
|
||||
) -> jsonify:
|
||||
"""
|
||||
生成失败响应
|
||||
|
||||
构建一个标准的失败响应JSON对象,包含状态码、错误信息等字段。
|
||||
支持通过kwargs添加额外的响应字段,便于扩展。
|
||||
|
||||
Args:
|
||||
msg: 错误提示信息,默认使用常量MESSAGE_FAIL
|
||||
code: 状态码,1表示失败,默认为1(可根据业务需求定义其他错误码)
|
||||
data: 错误时的附加数据,通常为None,默认为None
|
||||
**kwargs: 额外的键值对参数,会合并到响应体中
|
||||
|
||||
Returns:
|
||||
jsonify: 格式化的JSON响应对象
|
||||
|
||||
Examples:
|
||||
>>> return R.failed()
|
||||
{"code": 1, "data": None, "msg": "操作失败", "ok": False}
|
||||
|
||||
>>> return R.failed(msg="用户名或密码错误")
|
||||
{"code": 1, "data": None, "msg": "用户名或密码错误", "ok": False}
|
||||
|
||||
>>> return R.failed(msg="未授权访问", code=401)
|
||||
{"code": 401, "data": None, "msg": "未授权访问", "ok": False}
|
||||
|
||||
>>> return R.failed(msg="参数错误", errors={"name": "该字段不能为空"})
|
||||
{"code": 1, "data": None, "msg": "参数错误", "ok": False, "errors": {...}}
|
||||
"""
|
||||
# 构建基础响应体
|
||||
response_body: Dict[str, Any] = {
|
||||
"code": code,
|
||||
"data": data,
|
||||
"msg": msg,
|
||||
"ok": False
|
||||
}
|
||||
|
||||
# 合并扩展参数
|
||||
if kwargs:
|
||||
response_body.update(kwargs)
|
||||
|
||||
# 返回JSON响应
|
||||
return jsonify(response_body)
|
||||
|
||||
|
||||
def response(
|
||||
data: Optional[Any] = None,
|
||||
msg: str = MESSAGE_OK,
|
||||
code: int = 0,
|
||||
success: bool = True,
|
||||
**kwargs: Any
|
||||
) -> jsonify:
|
||||
"""
|
||||
通用响应生成器(高级用法)
|
||||
|
||||
提供更灵活的响应生成方式,允许自定义所有字段。
|
||||
通常推荐使用ok()和failed()两个专用方法,但在需要完全控制响应格式时可以使用此方法。
|
||||
|
||||
Args:
|
||||
data: 响应数据
|
||||
msg: 提示信息
|
||||
code: 状态码
|
||||
success: 操作是否成功标识
|
||||
**kwargs: 额外的键值对参数
|
||||
|
||||
Returns:
|
||||
jsonify: 格式化的JSON响应对象
|
||||
|
||||
Examples:
|
||||
>>> return R.response(data=[], msg="查询成功", code=200, success=True)
|
||||
{"code": 200, "data": [], "msg": "查询成功", "ok": True}
|
||||
"""
|
||||
response_body: Dict[str, Any] = {
|
||||
"code": code,
|
||||
"data": data,
|
||||
"msg": msg,
|
||||
"ok": success
|
||||
}
|
||||
|
||||
if kwargs:
|
||||
response_body.update(kwargs)
|
||||
|
||||
return jsonify(response_body)
|
||||
|
||||
|
||||
# 为方便使用,定义常用错误码常量(可选)
|
||||
class Codes:
|
||||
"""常用状态码定义"""
|
||||
SUCCESS = 0
|
||||
FAILED = 1
|
||||
UNAUTHORIZED = 401 # 未认证
|
||||
FORBIDDEN = 403 # 无权限
|
||||
NOT_FOUND = 404 # 资源不存在
|
||||
VALIDATE_ERROR = 422 # 验证错误
|
||||
SERVER_ERROR = 500 # 服务器错误
|
||||
@@ -0,0 +1,199 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 一米阳光团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
"""
|
||||
定时任务调度器扩展模块
|
||||
负责初始化和管理 APScheduler 定时任务
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import Flask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Scheduler:
|
||||
"""
|
||||
定时任务调度器管理类
|
||||
|
||||
负责调度器的初始化和生命周期管理
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化调度器实例"""
|
||||
self._initialized = False
|
||||
|
||||
def init_app(self, app: Flask):
|
||||
"""
|
||||
初始化定时任务调度器
|
||||
|
||||
Args:
|
||||
app: Flask 应用实例
|
||||
|
||||
使用示例:
|
||||
from extensions.scheduler import scheduler
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
# ... 其他配置
|
||||
scheduler.init_app(app)
|
||||
return app
|
||||
"""
|
||||
# 避免重复初始化
|
||||
if self._initialized:
|
||||
logger.warning("定时任务调度器已初始化,跳过重复初始化")
|
||||
return
|
||||
|
||||
# ============================================================
|
||||
# 在应用上下文中执行初始化
|
||||
# ============================================================
|
||||
with app.app_context():
|
||||
try:
|
||||
from application.services.scheduler import reload_scheduler
|
||||
|
||||
# 重新加载所有任务
|
||||
count = reload_scheduler()
|
||||
|
||||
self._initialized = True
|
||||
|
||||
if count > 0:
|
||||
logger.info(f"定时任务调度器初始化完成,已加载 {count} 个任务")
|
||||
else:
|
||||
logger.info("定时任务调度器初始化完成,当前无可用任务")
|
||||
|
||||
# 打印调度器状态(调试用)
|
||||
self._log_scheduler_status()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"定时任务调度器初始化失败: {str(e)}")
|
||||
raise
|
||||
|
||||
def _log_scheduler_status(self):
|
||||
"""打印调度器状态(用于调试)"""
|
||||
try:
|
||||
from application.services.scheduler import scheduler
|
||||
|
||||
if scheduler.running:
|
||||
jobs = scheduler.get_jobs()
|
||||
if jobs:
|
||||
logger.debug("当前调度器中的任务列表:")
|
||||
for job in jobs:
|
||||
logger.debug(f" - ID: {job.id}, Name: {job.name}, Next run: {job.next_run_time}")
|
||||
else:
|
||||
logger.debug("当前调度器中无任务")
|
||||
else:
|
||||
logger.warning("调度器未运行")
|
||||
except Exception as e:
|
||||
logger.error(f"获取调度器状态失败: {str(e)}")
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
关闭定时任务调度器
|
||||
|
||||
在应用退出时调用
|
||||
"""
|
||||
try:
|
||||
from application.services.scheduler import scheduler
|
||||
|
||||
if scheduler.running:
|
||||
scheduler.shutdown()
|
||||
logger.info("定时任务调度器已关闭")
|
||||
except Exception as e:
|
||||
logger.error(f"关闭调度器失败: {str(e)}")
|
||||
|
||||
def reload(self) -> int:
|
||||
"""
|
||||
重新加载所有定时任务
|
||||
|
||||
Returns:
|
||||
int: 成功加载的任务数量
|
||||
"""
|
||||
try:
|
||||
from application.services.scheduler import reload_scheduler
|
||||
count = reload_scheduler()
|
||||
logger.info(f"重新加载定时任务完成,共加载 {count} 个任务")
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.error(f"重新加载定时任务失败: {str(e)}")
|
||||
return 0
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""
|
||||
获取调度器状态
|
||||
|
||||
Returns:
|
||||
dict: 包含调度器运行状态和任务列表
|
||||
"""
|
||||
try:
|
||||
from application.services.scheduler import scheduler
|
||||
|
||||
jobs = scheduler.get_jobs()
|
||||
job_list = []
|
||||
|
||||
for job in jobs:
|
||||
job_list.append({
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'next_run_time': str(job.next_run_time) if job.next_run_time else None,
|
||||
'trigger': str(job.trigger)
|
||||
})
|
||||
|
||||
return {
|
||||
'initialized': self._initialized,
|
||||
'running': scheduler.running,
|
||||
'job_count': len(job_list),
|
||||
'jobs': job_list
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取调度器状态失败: {str(e)}")
|
||||
return {
|
||||
'initialized': self._initialized,
|
||||
'running': False,
|
||||
'job_count': 0,
|
||||
'jobs': [],
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 创建全局调度器单例实例
|
||||
# ============================================================
|
||||
scheduler = Scheduler()
|
||||
@@ -0,0 +1,121 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 珊瑚云团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 珊瑚云团队 荣誉出品
|
||||
# +======================================================================
|
||||
# | 版权和免责声明:
|
||||
# | 一、知识产权声明
|
||||
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
# | 二、使用限制与禁止条款
|
||||
# | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
# | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
# | 三、责任豁免
|
||||
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
# | 四、违约与法律责任
|
||||
# | 如发现任何违反本声明的行为,本团队有权:
|
||||
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
# | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
# | 五、许可范围说明
|
||||
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
# | 成的一切经济损失及商誉损害。
|
||||
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
# | 许可协议》附件为准。
|
||||
# +======================================================================
|
||||
|
||||
"""
|
||||
用户安全工具模块
|
||||
=============================================================
|
||||
|
||||
该模块提供了用户认证和安全相关的工具函数。
|
||||
用于快速获取当前登录用户的信息,简化业务代码中的用户信息获取操作。
|
||||
"""
|
||||
import logging
|
||||
|
||||
from flask import request
|
||||
|
||||
from src.core.jwt import parse_token
|
||||
|
||||
|
||||
# ============================= 用户信息获取工具 =============================
|
||||
|
||||
|
||||
def get_user_id():
|
||||
"""
|
||||
获取当前登录用户ID
|
||||
=============================================================
|
||||
|
||||
快速获取当前登录用户的ID,用于需要用户ID的业务场景。
|
||||
基于parse_token函数实现,简化调用方式。
|
||||
|
||||
使用场景:
|
||||
- 记录操作日志时需要记录操作人ID
|
||||
- 数据查询时进行用户权限过滤
|
||||
- 创建数据时自动关联当前用户
|
||||
|
||||
参数:
|
||||
request (HttpRequest): Django请求对象,包含请求头中的token信息
|
||||
|
||||
返回:
|
||||
int: 当前登录用户的ID,若token无效或用户不存在则返回0
|
||||
|
||||
异常:
|
||||
可能抛出parse_token函数产生的异常(如token过期、token无效等)
|
||||
建议调用方自行处理异常
|
||||
|
||||
示例:
|
||||
>>> user_id = get_user_id()
|
||||
>>> if user_id:
|
||||
... # 执行需要用户ID的操作
|
||||
... create_log(user_id, "操作内容")
|
||||
"""
|
||||
try:
|
||||
# 解析请求中的token,获取用户信息
|
||||
result = parse_token(request)
|
||||
|
||||
# 安全获取用户ID,避免KeyError
|
||||
if result and isinstance(result, dict):
|
||||
data = result.get('data', {})
|
||||
if isinstance(data, dict):
|
||||
return data.get('userId', 0)
|
||||
|
||||
return 0
|
||||
except Exception:
|
||||
# 记录异常日志(可根据需要添加日志记录)
|
||||
return 0
|
||||
|
||||
|
||||
def get_username():
|
||||
"""
|
||||
获取当前登录用户账号
|
||||
|
||||
Returns:
|
||||
str: 当前登录用户的账号(username),若token无效或用户不存在则返回空字符串
|
||||
"""
|
||||
try:
|
||||
result = parse_token(request)
|
||||
if result and isinstance(result, dict):
|
||||
data = result.get('data', {})
|
||||
if isinstance(data, dict):
|
||||
username = data.get('username')
|
||||
return username if username else ''
|
||||
return ''
|
||||
except Exception as e:
|
||||
logging.error(f"获取用户名失败: {str(e)}")
|
||||
return ''
|
||||
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
模型注册中心
|
||||
=============================================================
|
||||
|
||||
统一在此导入全部数据库模型,确保 Flask-Migrate 能全量识别表结构
|
||||
(migrations/env.py 依赖 db.metadata 收集所有表)。
|
||||
|
||||
P2 阶段:模型仍位于 application/models/,经其 __init__ 全量导入。
|
||||
P3 阶段:随模块逐个迁入 src/modules/,此处逐行改为
|
||||
from modules.<domain>.<name>.models import * 并移除对应旧行。
|
||||
P7 阶段:application/ 删除后,仅保留 src.modules 导入。
|
||||
"""
|
||||
|
||||
# ============================================================
|
||||
# P2:模型仍位于 application/models/(经其 __init__ 全量导入)
|
||||
# ============================================================
|
||||
import application.models # noqa: F401 (P3 起逐步替换为 src.modules 导入)
|
||||
|
||||
# ============================================================
|
||||
# P3 起按此格式逐模块替换:
|
||||
# from modules.system.user.models import * # noqa: F401,F403
|
||||
# from modules.system.role.models import * # noqa: F401,F403
|
||||
# from modules.system.menu.models import * # noqa: F401,F403
|
||||
# ...
|
||||
# ============================================================
|
||||
Reference in New Issue
Block a user