chore: 基线提交(重构前代码快照)

- git 初始化 + .gitignore 补全(根 .env / logs / instance / __pycache__ 等)
- 修复 utils/.gitignore 误将整个 utils/ 源码层忽略的问题,补入全部工具源码(含 ip2region.xdb)
- 含后端全部代码:application / extensions / utils / config / migrations
- 含前端 ui/(后续整体迁走)
- 不含根 .env(含密钥,本地保留)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DjangoAdmin Dev
2026-08-07 12:12:06 +08:00
co-authored by Claude
commit 15f2a5e097
591 changed files with 95894 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# ============================================================
# utils/ 目录专用忽略
# ============================================================
# 说明:
# 1. ip2region.xdb(10MB IP 数据库二进制)由版本库直接管理,
# 应用运行必需,不在此忽略。
# 2. 缓存目录由根目录 .gitignore 统一处理,此处仅留占位说明。
# ============================================================
+11
View File
@@ -0,0 +1,11 @@
"""
utils 包初始化
=============================================================
将常用工具函数从包级别导出,便于业务代码使用:
from utils import R, regular, password
"""
import utils.response as R
import utils.regular as regular
import utils.password as password
+232
View File
@@ -0,0 +1,232 @@
# +======================================================================
# | 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 utils.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图片数据
}
"""
from flask import request
# ============================================================
# 防刷:滑动窗口计数,限制同一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
+327
View File
@@ -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)
+407
View File
@@ -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)
+107
View File
@@ -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] + '...'
+55
View File
@@ -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 "未知"
+179
View File
@@ -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文件位于 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.
+91
View File
@@ -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)
+634
View File
@@ -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 utils.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 utils.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
+90
View File
@@ -0,0 +1,90 @@
"""
模型工具模块
=============================================================
提供SQLAlchemy模型对象的序列化工具:
- model_to_dict: 将单个模型对象转换为字典
- models_to_list: 将模型对象列表转换为字典列表
- format_datetime: 格式化datetime对象
"""
import logging
from datetime import datetime
from application.constants.common import SERIALIZE_EXCLUDE_FIELDS
from 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
]
+72
View File
@@ -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'))
+311
View File
@@ -0,0 +1,311 @@
"""
Redis客户端封装模块
=============================================================
提供Redis操作的统一封装,支持字符串、哈希、列表、集合、有序集合等数据类型。
自动处理序列化/反序列化,业务代码无需关心底层存储格式。
使用方式:
from utils.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 extensions.redis import redis_client
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()
+80
View File
@@ -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)
+196
View File
@@ -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 utils import R
return R.failed(msg="参数格式不正确,请使用JSON格式")
json_data = request.get_json()
if not json_data:
from utils import 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 utils import 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)}%'
+222
View File
@@ -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 application.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 # 服务器错误
+98
View File
@@ -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 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
+122
View File
@@ -0,0 +1,122 @@
# +======================================================================
# | 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 utils.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 as e:
# 记录异常日志(可根据需要添加日志记录)
# logger.error(f"获取用户ID失败: {str(e)}")
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 ''
+75
View File
@@ -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