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:
@@ -0,0 +1,8 @@
|
||||
# ============================================================
|
||||
# utils/ 目录专用忽略
|
||||
# ============================================================
|
||||
# 说明:
|
||||
# 1. ip2region.xdb(10MB IP 数据库二进制)由版本库直接管理,
|
||||
# 应用运行必需,不在此忽略。
|
||||
# 2. 缓存目录由根目录 .gitignore 统一处理,此处仅留占位说明。
|
||||
# ============================================================
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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] + '...'
|
||||
+55
@@ -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文件位于 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)
|
||||
+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 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
|
||||
@@ -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
|
||||
]
|
||||
@@ -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,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()
|
||||
@@ -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 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)}%'
|
||||
@@ -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 # 服务器错误
|
||||
@@ -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
|
||||
@@ -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 ''
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user