This commit is contained in:
2026-09-12 10:26:08 +08:00
parent 2548231e88
commit faa046bec9
12 changed files with 2950 additions and 3 deletions
+2 -1
View File
@@ -19,4 +19,5 @@ cbuild
.svn .svn
evui/ evui/
document/ document/
node_modules/ node_modules/
uploads/
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
+221
View File
@@ -0,0 +1,221 @@
# +======================================================================
# | 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 django.http import JsonResponse
from constant.constants import MESSAGE_OK, MESSAGE_FAIL
def ok(
data: Optional[Any] = None,
msg: str = MESSAGE_OK,
code: int = 0,
**kwargs: Any
) -> JsonResponse:
"""
生成成功响应
构建一个标准的成功响应JSON对象,包含状态码、数据、提示信息等字段。
支持通过kwargs添加额外的响应字段,便于扩展。
Args:
data: 响应数据,可以是任意类型(dict, list, str, int等),默认为None
msg: 成功提示信息,默认使用常量MESSAGE_OK
code: 状态码,0表示成功,默认为0
**kwargs: 额外的键值对参数,会合并到响应体中
Returns:
JsonResponse: 格式化的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 JsonResponse(data=response_body)
def failed(
msg: str = MESSAGE_FAIL,
code: int = 1,
data: Optional[Any] = None,
**kwargs: Any
) -> JsonResponse:
"""
生成失败响应
构建一个标准的失败响应JSON对象,包含状态码、错误信息等字段。
支持通过kwargs添加额外的响应字段,便于扩展。
Args:
msg: 错误提示信息,默认使用常量MESSAGE_FAIL
code: 状态码,1表示失败,默认为1(可根据业务需求定义其他错误码)
data: 错误时的附加数据,通常为None,默认为None
**kwargs: 额外的键值对参数,会合并到响应体中
Returns:
JsonResponse: 格式化的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 JsonResponse(data=response_body)
def response(
data: Optional[Any] = None,
msg: str = MESSAGE_OK,
code: int = 0,
success: bool = True,
**kwargs: Any
) -> JsonResponse:
"""
通用响应生成器(高级用法)
提供更灵活的响应生成方式,允许自定义所有字段。
通常推荐使用ok()和failed()两个专用方法,但在需要完全控制响应格式时可以使用此方法。
Args:
data: 响应数据
msg: 提示信息
code: 状态码
success: 操作是否成功标识
**kwargs: 额外的键值对参数
Returns:
JsonResponse: 格式化的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 JsonResponse(data=response_body)
# 为方便使用,定义常用错误码常量(可选)
class Codes:
"""常用状态码定义"""
SUCCESS = 0
FAILED = 1
UNAUTHORIZED = 401 # 未认证
FORBIDDEN = 403 # 无权限
NOT_FOUND = 404 # 资源不存在
VALIDATE_ERROR = 422 # 验证错误
SERVER_ERROR = 500 # 服务器错误
+670
View File
@@ -0,0 +1,670 @@
# +======================================================================
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
# +----------------------------------------------------------------------
# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有
# +----------------------------------------------------------------------
# | 开源协议: Apache-2.0
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
# +----------------------------------------------------------------------
# | 官方网站: https://www.djangoadmin.cn
# +----------------------------------------------------------------------
# | 研发团队: 小蚂蚁云团队 荣誉出品
# +======================================================================
# | 版权和免责声明:
# | 一、知识产权声明
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
# | 二、使用限制与禁止条款
# | 您理解并同意,禁止将本产品用于以下任何行为:
# | 1. 任何违反中华人民共和国法律法规的用途;
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
# | 三、责任豁免
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
# | 四、违约与法律责任
# | 如发现任何违反本声明的行为,本团队有权:
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
# | 3. 保留进一步采取刑事控告等法律措施的权利。
# | 五、许可范围说明
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
# | 成的一切经济损失及商誉损害。
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
# | 许可协议》附件为准。
# +======================================================================
"""
通用工具函数模块
=============================================================
该模块提供项目中广泛使用的通用工具函数,涵盖token解析、类型判断、
文件处理、文件操作等常用功能。这些工具函数在整个项目中重复使用,
提供了统一的处理逻辑,减少代码冗余。
主要功能:
1. Token解析工具 - 从请求中解析JWT token,获取用户信息
2. 类型判断工具 - 判断变量类型并返回可读的类型名称
3. 文件处理工具 - 文件URL处理、临时文件迁移、远程文件保存
4. 富文本内容处理 - 提取并保存富文本中的文件
5. IP地址处理 - 获取客户端IP和地理位置
6. User-Agent解析 - 解析操作系统和浏览器信息
7. 参数格式化 - 格式化请求参数和返回结果
所属包: utils
所在文件: utils/utils.py
依赖模块: os, re, time - 标准库
config.env - 项目配置
utils.file.mkdir - 目录创建工具
"""
import logging
import os
import time
from django.conf import settings
from config.env import DJANGO_FILE_URL, DJANGO_TEMP_PATH, DJANGO_UPLOAD_DIR
import json
import re
import urllib.request
from utils.file import mkdir
# 获取日志记录器
logger = logging.getLogger(__name__)
# ============================= 文件处理工具 =============================
# 获取文件地址
def get_file_url(path):
"""
获取完整的文件访问URL
=============================================================
将文件的相对路径拼接上文件域名,生成完整的可访问URL。
如果路径为空,直接返回None,避免空值拼接。
参数:
path (str): 文件的相对路径,可以是空字符串、None或有效路径
返回:
str or None: 完整的文件访问URL,如果path为空则返回None
"""
# 参数验证:如果路径为空或None,直接返回None
if not path:
return None
# 拼接文件域名和相对路径
return DJANGO_FILE_URL + path
def save_file(url, directory):
"""
保存文件到指定目录
=============================================================
处理文件保存逻辑,主要针对本站临时文件进行迁移。
将临时目录中的文件移动到正式存储目录,并返回新的相对路径。
参数:
url (str): 文件的完整URL或相对路径
dirname (str): 目标子目录名称,用于分类存储
返回:
str: 文件的相对路径
失败时返回空字符串
处理流程:
1. 检查URL有效性
2. 判断是否为本站文件
3. 判断是否为临时文件
4. 创建目标目录
5. 移动文件到正式目录
6. 返回新路径
文件分类说明:
- 临时文件:存储在 temp 目录下,需要迁移
- 正式文件:已存储在 images 目录下,直接返回相对路径
- 远程文件:其他站点的文件,暂未实现处理逻辑
"""
# 判断文件地址是否为空
if not url:
return "文件地址不能为空"
# 判断是否是本站文件
# 说明:通过判断URL是否包含文件域名来识别本站文件
if url.find(DJANGO_FILE_URL) != -1:
# 本站文件处理
if url.find("/temp") != -1:
# 临时文件处理
# 说明:临时文件需要移动到正式目录
# 构建目标目录路径
# 格式:正式文件路径/分类目录/日期目录
path = DJANGO_UPLOAD_DIR + "/" + directory + "/" + time.strftime('%Y%m%d')
# 创建存储目录
# 函数:mkdir(path)
# 说明:确保目标目录存在
mkdir(path)
# 原始文件地址
# 说明:将URL转换为本地文件系统路径
old_path = url.replace(DJANGO_FILE_URL, DJANGO_UPLOAD_DIR)
# 目标目录地址
# 说明:构建移动后的新路径,移除temp目录部分
new_path = DJANGO_UPLOAD_DIR + "/" + directory + old_path.replace(DJANGO_TEMP_PATH, "")
# 移动文件
# 函数:os.rename(oldPath, newPath)
# 说明:将文件从临时位置移动到正式位置
os.rename(old_path, new_path)
# 返回结果
# 说明:返回新路径的相对部分(不包含上传目录)
return new_path.replace(DJANGO_UPLOAD_DIR, "")
else:
# 非临时文件处理
# 说明:已经是正式文件,直接返回相对路径
return url.replace(DJANGO_FILE_URL, "")
else:
# 远程文件处理
# 说明:非本站文件,暂未实现下载保存逻辑
print("远程文件处理")
# 返回空字符串
return ""
def save_content(content, title, directory):
"""
保存富文本编辑器内容
=============================================================
处理富文本编辑器提交的内容,提取其中的文件并进行保存。
同时处理文件的alt属性,将其设置为文章标题。
参数:
content (str): 富文本HTML内容
title (str): 文章标题,用于设置文件alt属性
dirname (str): 文件存储的子目录名称
返回:
str: 处理后的富文本内容,文件地址已被替换
处理流程:
1. 使用正则表达式提取所有img标签的src属性
2. 遍历每个文件URL,调用save_file保存
3. 将原URL替换为新URL(带[IMG_URL]标记)
4. 设置空alt为文章标题
5. 返回处理后的内容
正则表达式说明:
'img src="(.*?)"'
- img src=" : 匹配img标签的src属性开始
- (.*?) : 非贪婪匹配,捕获引号内的URL
- " : 匹配结束引号
- re.S : 使.匹配包括换行符在内的所有字符
"""
# 正则取出富文本文件地址
# 说明:找出所有img标签中的src属性值
image_urls = re.findall('img src="(.*?)"', content, re.S)
# 遍历文件地址
for url in image_urls:
# 保存文件至本地
# 函数:save_file(url, dirname)
# 说明:处理文件保存,返回新路径
image = save_file(url, directory)
if image:
# 替换原文件地址
# 说明:将原URL替换为新地址,添加[IMG_URL]标记用于后续处理
content = content.replace(url, "[IMG_URL]" + image)
# 设置alt标题
# 说明:为没有alt属性的文件设置标题,有利于SEO
# 确保 title 是字符串类型
title = str(title) if title else ""
if content.find("alt=\"\"") and title:
content = content.replace("alt=\"\"", "alt=\"" + title + "\"")
# 返回结果
return content
def get_table_name(base_name):
"""获取带前缀的表名"""
prefix = getattr(settings, 'DATABASE_PREFIX', '')
return f"{prefix}{base_name}"
# ============================= IP地址处理工具 =============================
def get_client_ip(request) -> str:
"""
获取客户端真实IP地址
=============================================================
通过HTTP请求头获取客户端的真实IP地址,支持代理环境。
优先从 HTTP_X_FORWARDED_FOR 获取,其次使用 REMOTE_ADDR。
Args:
request: HttpRequest对象
Returns:
str: 客户端IP地址
处理逻辑:
1. 检查 HTTP_X_FORWARDED_FOR 头(适用于代理环境)
2. 如果存在,取第一个IP(客户端真实IP)
3. 否则使用 REMOTE_ADDR(直接连接IP)
使用示例:
>>> ip = get_client_ip(request)
>>> print(f"客户端IP: {ip}")
"""
# 获取代理转发的IP列表
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
# 取第一个IP(客户端真实IP)
ip = x_forwarded_for.split(',')[0]
else:
# 直接获取连接IP
ip = request.META.get('REMOTE_ADDR')
return ip
def get_ip_location(ip: str) -> str:
"""
根据IP获取地理位置(使用纯真IP库或第三方API)
=============================================================
调用第三方IP地理位置API,获取IP所在的国家、省份、城市信息。
Args:
ip: IP地址
Returns:
str: 地理位置信息,格式:"国家 省份 城市"
处理逻辑:
1. 内网IP直接返回"内网IP"
2. 调用 ip-api.com API 获取地理位置
3. 解析返回的JSON数据
4. 返回格式化后的地理位置
注意:
- ip-api.com 免费版限制45次/分钟
- 生产环境建议使用本地IP库如纯真IP库
"""
# 内网IP或本地IP判断
if ip in ['127.0.0.1', 'localhost'] or ip.startswith('192.168.') or ip.startswith('10.'):
return "内网IP"
try:
# 使用ip-api.com获取地理位置(免费,限制45次/分钟)
# 生产环境建议使用本地IP库如纯真IP库
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 "未知"
# ============================= User-Agent解析工具 =============================
def parse_user_agent(user_agent: str) -> dict:
"""
解析User-Agent,获取操作系统和浏览器信息
=============================================================
通过正则表达式匹配User-Agent字符串,识别用户的操作系统和浏览器类型。
Args:
user_agent: User-Agent字符串
Returns:
dict: 包含os和browser的字典
{
'os': '操作系统',
'browser': '浏览器'
}
识别规则:
操作系统:
- Windows系列: Windows 10/8/7/XP
- macOS: macOS
- Linux: Linux
- Android: Android
- iOS: iOS
浏览器:
- Edge: Edge
- Chrome: Chrome
- Safari: Safari
- Firefox: Firefox
- IE: IE
使用示例:
>>> ua = request.META.get('HTTP_USER_AGENT', '')
>>> info = parse_user_agent(ua)
>>> print(f"操作系统: {info['os']}, 浏览器: {info['browser']}")
"""
# 初始化返回结果
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'),
(r'chrome', 'Chrome'),
(r'safari', 'Safari'),
(r'firefox', 'Firefox'),
(r'msie', 'IE'),
(r'trident', 'IE'),
]
# 遍历匹配浏览器
for pattern, browser_name in browser_patterns:
if re.search(pattern, ua_lower):
result['browser'] = browser_name
break
return result
# ============================= 参数格式化工具 =============================
def format_params(params) -> str:
"""
格式化请求参数,用于日志存储
=============================================================
将请求参数字典转换为JSON字符串,并过滤敏感信息。
Args:
params: 请求参数(字典或其他类型)
Returns:
str: 格式化后的JSON字符串
处理逻辑:
1. 如果参数为空,返回空字符串
2. 如果是字典类型,复制并过滤敏感字段
3. 敏感字段值替换为 '******'
4. 转换为JSON字符串
5. 如果转换失败,返回字符串表示(限制500字符)
敏感字段:
- password: 密码
- old_password: 旧密码
- new_password: 新密码
- confirm_password: 确认密码
使用示例:
>>> params = {'username': 'admin', 'password': '123456'}
>>> result = format_params(params)
>>> # 结果: {"username": "admin", "password": "******"}
"""
# 空值判断
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] = '******'
# 转换为JSON字符串
return json.dumps(params_copy, ensure_ascii=False)
# 非字典类型直接转换
return json.dumps(params, ensure_ascii=False)
except Exception:
# 转换失败时,返回字符串表示(限制500字符)
return str(params)[:500]
def get_method_name(request):
"""
获取请求方法名(如:user.add)
=============================================================
从Django的路由解析结果中获取视图方法名称。
Args:
request: HttpRequest对象
Returns:
str: 方法名称,获取失败返回空字符串
使用场景:
- 操作日志记录,标识具体执行的方法
- 权限验证,根据方法名判断权限
注意:
- 需要在URL配置中定义了name属性才能获取到
- 如果未定义name,返回空字符串
"""
# 从request.resolver_match获取路由名称
if hasattr(request, 'resolver_match') and request.resolver_match:
return request.resolver_match.view_name or ''
return ''
def format_result(result):
"""
格式化返回结果
=============================================================
将返回结果转换为适合存储的字符串格式,支持JsonResponse、字典等类型。
Args:
result: 返回结果(JsonResponse 或其他类型)
Returns:
str: 格式化后的结果字符串
处理逻辑:
1. 空值判断
2. JsonResponse类型:解析content内容
3. 字典类型:转换为JSON字符串
4. 其他类型:转换为字符串
5. 长度超过2000字符时截断
使用场景:
- 操作日志记录,保存接口返回结果
- 接口调试,查看返回数据
使用示例:
>>> response = R.ok(data={'id': 1, 'name': '张三'})
>>> result_str = format_result(response)
>>> # 结果: {"code": 0, "data": {"id": 1, "name": "张三"}, "msg": "操作成功", "ok": true}
"""
# 空值判断
if not result:
return ''
try:
# 处理 JsonResponse 对象(R.ok 或 R.failed 返回的类型)
if hasattr(result, 'content') and result.content:
# 解码响应内容
content = result.content.decode('utf-8')
if content:
try:
# 尝试解析并格式化 JSON
data = json.loads(content)
result_str = json.dumps(data, ensure_ascii=False)
except:
# 不是 JSON 直接返回字符串
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:
str: 截断后的字符串
处理逻辑:
1. 空值判断,返回空字符串
2. 长度未超限,返回原字符串
3. 长度超限,截取前max_len个字符,添加省略号
使用示例:
>>> long_text = 'a' * 3000
>>> result = truncate_json(long_text, 2000)
>>> # 结果: 'a' * 2000 + '...'
"""
# 空值判断
if not json_str:
return ''
# 长度判断
if len(json_str) <= max_len:
return json_str
# 截断并添加省略号
return json_str[:max_len] + '...'
def parse_request_body(request):
"""
解析请求体JSON数据
Args:
request: HttpRequest对象
Returns:
tuple: (data, error)
成功时 data 为解析后的字典,error 为 None
失败时 data 为 None,error 为错误描述字符串
Examples:
>>> data, error = parse_request_body(request)
>>> if error:
... return R.failed(error)
>>> username = data.get('username')
"""
try:
# 检查请求体是否存在
if not hasattr(request, 'body') or request.body is None:
# 没有请求体,返回空数据但不报错
return None, None
# 获取请求体内容
if not request.body:
return None, "参数不能为空"
json_data = request.body.decode('utf-8')
if not json_data:
return None, "参数不能为空"
# 解析JSON
data = json.loads(json_data)
return data, None
except json.JSONDecodeError as e:
logging.error(f"JSON解析异常: {str(e)}")
return None, "参数格式错误"
except UnicodeDecodeError as e:
logging.error(f"字符编码异常: {str(e)}")
return None, "参数编码错误"
except Exception as e:
logging.error(f"解析请求体异常: {str(e)}")
return None, "参数处理失败"
def format_datetime(dt, format_str='%Y-%m-%d %H:%M:%S'):
"""
格式化日期时间
Args:
dt: datetime对象
format_str: 日期时间格式字符串,默认为 '%Y-%m-%d %H:%M:%S'
Returns:
str: 格式化后的日期字符串,如果输入为None则返回None
"""
return dt.strftime(format_str) if dt else None
+342
View File
@@ -0,0 +1,342 @@
# +======================================================================
# | 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 os
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)
print(path + ' 创建成功')
return True
else:
# 如果目录存在则不创建,并提示目录已存在
# 说明:目录已存在是正常情况,不需要重复创建
print(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:
print(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:
print(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:
print(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)
+243
View File
@@ -0,0 +1,243 @@
# +======================================================================
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
# +----------------------------------------------------------------------
# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有
# +----------------------------------------------------------------------
# | 开源协议: Apache-2.0
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
# +----------------------------------------------------------------------
# | 官方网站: https://www.djangoadmin.cn
# +----------------------------------------------------------------------
# | 研发团队: 小蚂蚁云团队 荣誉出品
# +======================================================================
# | 版权和免责声明:
# | 一、知识产权声明
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
# | 二、使用限制与禁止条款
# | 您理解并同意,禁止将本产品用于以下任何行为:
# | 1. 任何违反中华人民共和国法律法规的用途;
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
# | 三、责任豁免
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
# | 四、违约与法律责任
# | 如发现任何违反本声明的行为,本团队有权:
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
# | 3. 保留进一步采取刑事控告等法律措施的权利。
# | 五、许可范围说明
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
# | 成的一切经济损失及商誉损害。
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
# | 许可协议》附件为准。
# +======================================================================
# +======================================================================
# | 模块: IP地理位置查询
# | 说明: 基于 ip2region.xdb 离线数据库提供高性能IP地理位置查询
# +======================================================================
"""
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,
}
# ============================================================
# 内网IP判断
# ============================================================
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:
try:
second = int(parts[1])
except (TypeError, ValueError):
# 畸形IP(如 172.abc):无法判定内网,按非内网处理,
# 避免抛异常冒泡导致 record_login_log/record_operation_log 整段中断
return False
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": "未知", ...}
"""
try:
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": ""}
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:
# 畸形IP/查询异常一律降级"未知",绝不向上抛异常中断登录/操作日志落库
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.
+508
View File
@@ -0,0 +1,508 @@
# +======================================================================
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
# +----------------------------------------------------------------------
# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有
# +----------------------------------------------------------------------
# | 开源协议: Apache-2.0
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
# +----------------------------------------------------------------------
# | 官方网站: https://www.djangoadmin.cn
# +----------------------------------------------------------------------
# | 研发团队: 小蚂蚁云团队 荣誉出品
# +======================================================================
# | 版权和免责声明:
# | 一、知识产权声明
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
# | 二、使用限制与禁止条款
# | 您理解并同意,禁止将本产品用于以下任何行为:
# | 1. 任何违反中华人民共和国法律法规的用途;
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
# | 三、责任豁免
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
# | 四、违约与法律责任
# | 如发现任何违反本声明的行为,本团队有权:
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
# | 3. 保留进一步采取刑事控告等法律措施的权利。
# | 五、许可范围说明
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
# | 成的一切经济损失及商誉损害。
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
# | 许可协议》附件为准。
# +======================================================================
"""
IP地理位置解析模块
=============================================================
该模块提供根据IP地址获取地理位置信息的功能。
支持多种IP数据库格式(如纯真IP数据库、GeoIP2等),
并提供缓存机制,减少重复查询。
主要功能:
1. 根据IP地址获取省市信息
2. 支持多种IP数据库格式
3. 提供缓存机制,提高查询效率
4. 支持本地数据库和在线API两种方式
依赖模块:
- requests: 调用在线API(可选)
- ipip-ipdb: 支持IPIP.NET数据库(可选)
- geoip2: 支持MaxMind GeoIP2数据库(可选)
"""
import os
import json
import logging
import hashlib
from functools import lru_cache
from typing import Optional, Tuple, Dict
import requests
from django.core.cache import cache
logger = logging.getLogger(__name__)
# =============================================================
# 配置项
# =============================================================
# IP数据库类型配置
# 可选值: 'local' - 本地数据库, 'api' - 在线API
IP_LOCATION_SOURCE = getattr(__import__('config.env', fromlist=['IP_LOCATION_SOURCE']),
'IP_LOCATION_SOURCE', 'api')
# 本地数据库文件路径
IP_DATABASE_PATH = getattr(__import__('config.env', fromlist=['IP_DATABASE_PATH']),
'IP_DATABASE_PATH',
os.path.join(os.path.dirname(__file__), 'data', 'qqwry.ipdb'))
# 在线API配置
IP_API_URL = getattr(__import__('config.env', fromlist=['IP_API_URL']),
'IP_API_URL', 'https://ip.taobao.com/outGetIpInfo')
IP_API_TIMEOUT = 5 # API请求超时时间(秒)
# 缓存配置
IP_CACHE_ENABLED = True # 是否启用缓存
IP_CACHE_TIMEOUT = 86400 # 缓存时间(秒),默认1天
# 默认返回信息(当无法获取时)
DEFAULT_LOCATION = "未知"
DEFAULT_PROVINCE = "未知"
DEFAULT_CITY = "未知"
# =============================================================
# IP数据库加载器
# =============================================================
class IPDatabaseLoader:
"""
IP数据库加载器
支持多种IP数据库格式:
1. 纯真IP数据库(QQWry)
2. IPIP.NET数据库
3. MaxMind GeoIP2数据库
"""
_instance = None
_db = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
self._db = None
self._load_database()
def _load_database(self):
"""加载IP数据库"""
if IP_LOCATION_SOURCE != 'local':
return
try:
# 尝试加载IPIP.NET数据库(.ipdb格式)
if os.path.exists(IP_DATABASE_PATH) and IP_DATABASE_PATH.endswith('.ipdb'):
import ipip
self._db = ipip.IPDatabase.open(IP_DATABASE_PATH)
logger.info(f"加载IPIP数据库成功: {IP_DATABASE_PATH}")
# 尝试加载纯真IP数据库(.dat格式)
elif os.path.exists(IP_DATABASE_PATH) and IP_DATABASE_PATH.endswith('.dat'):
import qqwry
self._db = qqwry.QQWry(IP_DATABASE_PATH)
logger.info(f"加载纯真IP数据库成功: {IP_DATABASE_PATH}")
# 尝试加载GeoIP2数据库
elif os.path.exists(IP_DATABASE_PATH) and IP_DATABASE_PATH.endswith('.mmdb'):
import geoip2.database
self._db = geoip2.database.Reader(IP_DATABASE_PATH)
logger.info(f"加载GeoIP2数据库成功: {IP_DATABASE_PATH}")
else:
logger.warning(f"IP数据库文件不存在或格式不支持: {IP_DATABASE_PATH}")
except ImportError as e:
logger.warning(f"IP数据库依赖未安装: {e}")
except Exception as e:
logger.error(f"加载IP数据库失败: {e}")
def get_location(self, ip: str) -> Tuple[str, str, str]:
"""
从本地数据库获取IP地理位置
Args:
ip: IP地址
Returns:
Tuple[str, str, str]: (省份, 城市, 详细地址)
"""
if not self._db:
return DEFAULT_PROVINCE, DEFAULT_CITY, DEFAULT_LOCATION
try:
# IPIP.NET数据库查询
if hasattr(self._db, 'find'):
info = self._db.find(ip)
if info:
# IPIP.NET返回格式: ['中国', '广东', '深圳', '电信']
province = info[1] if len(info) > 1 else DEFAULT_PROVINCE
city = info[2] if len(info) > 2 else DEFAULT_CITY
location = f"{province} {city}".strip()
return province, city, location
# 纯真IP数据库查询
elif hasattr(self._db, 'lookup'):
result = self._db.lookup(ip)
if result:
# 解析纯真数据库返回的地址
location_str = str(result)
parts = location_str.split()
province = parts[0] if parts else DEFAULT_PROVINCE
city = parts[1] if len(parts) > 1 else DEFAULT_CITY
return province, city, location_str
# GeoIP2数据库查询
elif hasattr(self._db, 'city'):
response = self._db.city(ip)
province = response.country.name if response.country.name else DEFAULT_PROVINCE
city = response.city.name if response.city.name else DEFAULT_CITY
location = f"{province} {city}".strip()
return province, city, location
except Exception as e:
logger.error(f"IP数据库查询失败: {ip}, 错误: {e}")
return DEFAULT_PROVINCE, DEFAULT_CITY, DEFAULT_LOCATION
# =============================================================
# 在线API查询器
# =============================================================
class IPAPILoader:
"""
在线IP查询API加载器
支持多种在线API服务:
1. 淘宝IP地址库(默认)
2. 百度IP定位API
3. 高德IP定位API
4. ip-api.com
"""
@staticmethod
def get_location(ip: str) -> Tuple[str, str, str]:
"""
通过在线API获取IP地理位置
Args:
ip: IP地址
Returns:
Tuple[str, str, str]: (省份, 城市, 详细地址)
"""
# 本地IP不需要查询
if ip in ['127.0.0.1', 'localhost', '::1']:
return "本地", "本地", "本地"
# 内网IP不需要查询
if ip.startswith(('192.168.', '10.', '172.16.', '172.17.', '172.18.',
'172.19.', '172.20.', '172.21.', '172.22.', '172.23.',
'172.24.', '172.25.', '172.26.', '172.27.', '172.28.',
'172.29.', '172.30.', '172.31.')):
return "内网", "内网", "内网IP"
# 尝试多种API
apis = [
IPAPILoader._query_taobao,
IPAPILoader._query_ip_api,
IPAPILoader._query_pconline,
]
for api in apis:
try:
province, city, location = api(ip)
if province != DEFAULT_PROVINCE:
return province, city, location
except Exception as e:
logger.debug(f"API查询失败 {api.__name__}: {e}")
continue
return DEFAULT_PROVINCE, DEFAULT_CITY, DEFAULT_LOCATION
@staticmethod
def _query_taobao(ip: str) -> Tuple[str, str, str]:
"""
淘宝IP地址库API
API文档: https://ip.taobao.com/
"""
url = f"https://ip.taobao.com/outGetIpInfo"
params = {
'ip': ip,
'accessKey': 'alibaba-inc'
}
response = requests.get(url, params=params, timeout=IP_API_TIMEOUT)
data = response.json()
if data.get('code') == 0:
ip_data = data.get('data', {})
province = ip_data.get('region', DEFAULT_PROVINCE)
city = ip_data.get('city', DEFAULT_CITY)
location = f"{province} {city}".strip()
if location == " XX":
location = province
return province, city, location
raise Exception(f"淘宝API返回错误: {data.get('msg')}")
@staticmethod
def _query_ip_api(ip: str) -> Tuple[str, str, str]:
"""
ip-api.com API (免费版)
API文档: http://ip-api.com/
"""
url = f"http://ip-api.com/json/{ip}"
params = {
'lang': 'zh-CN',
'fields': 'status,country,regionName,city'
}
response = requests.get(url, params=params, timeout=IP_API_TIMEOUT)
data = response.json()
if data.get('status') == 'success':
province = data.get('regionName', DEFAULT_PROVINCE)
city = data.get('city', DEFAULT_CITY)
location = f"{data.get('country', '')} {province} {city}".strip()
return province, city, location
raise Exception(f"ip-api返回错误")
@staticmethod
def _query_pconline(ip: str) -> Tuple[str, str, str]:
"""
太平洋电脑网IP查询API
"""
url = f"https://whois.pconline.com.cn/ipJson.jsp"
params = {
'ip': ip,
'json': 'true'
}
response = requests.get(url, params=params, timeout=IP_API_TIMEOUT)
# 太平洋API返回的是GBK编码
response.encoding = 'gbk'
data = response.text
# 解析JSONP格式的返回
if data.startswith('try{'):
data = data[4:-2]
result = json.loads(data)
if result:
province = result.get('pro', DEFAULT_PROVINCE)
city = result.get('city', DEFAULT_CITY)
location = result.get('addr', DEFAULT_LOCATION)
return province, city, location
raise Exception("太平洋API返回错误")
# =============================================================
# 主入口函数
# =============================================================
def get_ip_location(ip: str, use_cache: bool = True) -> Dict[str, str]:
"""
根据IP地址获取地理位置信息
Args:
ip: IP地址字符串
use_cache: 是否使用缓存,默认True
Returns:
Dict[str, str]: 地理位置信息,包含以下字段:
- province: 省份
- city: 城市
- location: 详细地址
- ip: 原始IP地址
使用示例:
>>> location = get_ip_location('183.14.30.110')
>>> print(location['province']) # 广东省
>>> print(location['city']) # 深圳市
>>> print(location['location']) # 广东省 深圳市
Notes:
1. 支持IPv4和IPv6地址
2. 内网IP返回"内网"标识
3. 查询结果会缓存,提高性能
"""
# 参数校验
if not ip or ip == 'unknown':
return _build_location_result(DEFAULT_PROVINCE, DEFAULT_CITY, DEFAULT_LOCATION, ip)
# 检查缓存
cache_key = f"ip_location_{ip}"
if use_cache and IP_CACHE_ENABLED:
cached = cache.get(cache_key)
if cached:
logger.debug(f"从缓存获取IP地理位置: {ip} -> {cached}")
return cached
try:
# 根据配置选择查询方式
if IP_LOCATION_SOURCE == 'local':
# 使用本地数据库
loader = IPDatabaseLoader()
province, city, location = loader.get_location(ip)
else:
# 使用在线API
province, city, location = IPAPILoader.get_location(ip)
# 构建结果
result = _build_location_result(province, city, location, ip)
# 缓存结果
if use_cache and IP_CACHE_ENABLED:
cache.set(cache_key, result, IP_CACHE_TIMEOUT)
return result
except Exception as e:
logger.error(f"获取IP地理位置失败: {ip}, 错误: {e}")
return _build_location_result(DEFAULT_PROVINCE, DEFAULT_CITY, DEFAULT_LOCATION, ip)
def get_ip_province(ip: str) -> str:
"""
获取IP所在省份
Args:
ip: IP地址
Returns:
str: 省份名称
"""
location = get_ip_location(ip)
return location.get('province', DEFAULT_PROVINCE)
def get_ip_city(ip: str) -> str:
"""
获取IP所在城市
Args:
ip: IP地址
Returns:
str: 城市名称
"""
location = get_ip_location(ip)
return location.get('city', DEFAULT_CITY)
def _build_location_result(province: str, city: str, location: str, ip: str) -> Dict[str, str]:
"""
构建地理位置结果字典
Args:
province: 省份
city: 城市
location: 详细地址
ip: IP地址
Returns:
Dict[str, str]: 标准化的地理位置信息
"""
# 清理数据
province = province.strip() if province else DEFAULT_PROVINCE
city = city.strip() if city else DEFAULT_CITY
location = location.strip() if location else f"{province} {city}".strip()
# 如果省份和城市相同,只显示一个
if province == city:
location = province
return {
'province': province,
'city': city,
'location': location,
'ip': ip
}
def batch_get_ip_locations(ips: list) -> Dict[str, Dict[str, str]]:
"""
批量获取多个IP的地理位置信息
Args:
ips: IP地址列表
Returns:
Dict[str, Dict[str, str]]: IP地址到地理位置信息的映射
"""
result = {}
for ip in ips:
result[ip] = get_ip_location(ip)
return result
# =============================================================
# 便捷函数
# =============================================================
def is_internal_ip(ip: str) -> bool:
"""
判断是否为内网IP
Args:
ip: IP地址
Returns:
bool: 是否为内网IP
"""
if ip in ['127.0.0.1', 'localhost', '::1']:
return True
if ip.startswith(('192.168.', '10.', '172.16.', '172.17.', '172.18.',
'172.19.', '172.20.', '172.21.', '172.22.', '172.23.',
'172.24.', '172.25.', '172.26.', '172.27.', '172.28.',
'172.29.', '172.30.', '172.31.')):
return True
return False
+623
View File
@@ -0,0 +1,623 @@
# +======================================================================
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
# +----------------------------------------------------------------------
# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有
# +----------------------------------------------------------------------
# | 开源协议: Apache-2.0
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
# +----------------------------------------------------------------------
# | 官方网站: https://www.djangoadmin.cn
# +----------------------------------------------------------------------
# | 研发团队: 小蚂蚁云团队 荣誉出品
# +======================================================================
# | 版权和免责声明:
# | 一、知识产权声明
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
# | 二、使用限制与禁止条款
# | 您理解并同意,禁止将本产品用于以下任何行为:
# | 1. 任何违反中华人民共和国法律法规的用途;
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
# | 三、责任豁免
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
# | 四、违约与法律责任
# | 如发现任何违反本声明的行为,本团队有权:
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
# | 3. 保留进一步采取刑事控告等法律措施的权利。
# | 五、许可范围说明
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
# | 成的一切经济损失及商誉损害。
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
# | 许可协议》附件为准。
# +======================================================================
# +======================================================================
# | 模块: JWT令牌处理
# | 说明: 提供JWT令牌的生成、验证、刷新和黑名单管理功能
# +======================================================================
"""
JWT令牌处理模块
=============================================================
该模块提供基于JWT(JSON Web Token)的令牌生成和验证功能。
主要用于用户登录认证,生成访问令牌,并在后续请求中验证令牌的有效性。
支持令牌过期机制,确保系统的安全性。
主要功能:
1. JWT令牌生成(create_token)- 用于用户登录成功后生成访问令牌
2. JWT令牌解析验证(parse_payload)- 验证请求中的令牌有效性
3. 统一的异常处理,返回标准格式的验证结果
4. 可配置的令牌过期时间
所属包: utils
所在文件: utils/jwt.py
依赖模块: jwt - JWT处理第三方库
datetime - 时间处理模块
hashlib - 摘要算法模块
os - 环境变量读取模块
logging - 日志记录模块
"""
import datetime
import hashlib
import logging
import os
import time
import jwt
from jwt import exceptions
# 配置日志记录器
logger = logging.getLogger(__name__)
# =============================================================
# JWT密钥配置
# =============================================================
# 优先从环境变量读取JWT密钥
JWT_SALT = os.environ.get('JWT_SALT', None)
# 如果没有环境变量,使用默认密钥
if not JWT_SALT:
JWT_SALT = "ds()udsjo@jlsdosjf)wjd_#(#)$%^&*()_+=-0987654321"
# JWT算法配置
JWT_ALGORITHM = 'HS256'
# 默认令牌过期时间(分钟)
DEFAULT_TIMEOUT_MINUTES = 20
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:
logger.warning(
f"JWT密钥长度不足:{key_bytes} 字节,建议至少 32 字节(256位)。"
)
logger.info(f"JWT密钥校验通过,长度:{key_bytes}字节")
# 启动时校验密钥强度
_validate_key_strength(JWT_SALT)
# =============================================================
# 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
# 检查Token是否在黑名单中
if is_token_blacklisted(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(request):
"""
从请求头中提取 access_token
Args:
request: Django 请求对象
Returns:
tuple: (success, token, message)
"""
try:
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
if not auth_header:
return False, None, '缺少 Authorization 头'
# Bearer 认证方案前缀大小写不敏感(RFC 7235),兼容 'Bearer'/'bearer'/'BEARER'
stripped = auth_header.strip()
if stripped.lower().startswith('bearer '):
access_token = stripped[7:].strip()
else:
access_token = stripped
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)}'
def parse_token(request):
"""
解析请求头中的token,返回解析结果
Args:
request: Django 请求对象
Returns:
dict: 包含解析结果和用户信息的字典
"""
result = {
'code': -1,
'msg': 'token无效',
'data': {
'userId': 0,
'username': None
}
}
try:
success, access_token, msg = get_access_token(request)
if not success:
result['msg'] = msg
return result
parse_result = parse_payload(access_token)
if not isinstance(parse_result, dict):
result['msg'] = '解析结果格式错误'
return result
code = parse_result.get('code', 1)
if code != 0:
result['msg'] = parse_result.get('msg', 'token验证失败')
return result
data = parse_result.get('data', {})
if not isinstance(data, dict):
result['msg'] = '数据格式错误'
return result
result['code'] = 0
result['msg'] = 'success'
result['data']['userId'] = data.get('userId', 0)
result['data']['username'] = data.get('username', '')
result['data']['realname'] = data.get('realname', '')
except Exception as e:
result['msg'] = f'token解析异常: {str(e)}'
return result
# =============================================================
# Token黑名单功能
# =============================================================
# 黑名单Key前缀,用于Redis中存储被注销的Token
_TOKEN_BLACKLIST_PREFIX = 'token:blacklist:'
# 黑名单检查告警节流:首次失败记 ERROR,随后每 _BLACKLIST_ALERT_INTERVAL 秒至多一条 ERROR,期间重复失败降为 DEBUG
_BLACKLIST_ALERT_INTERVAL = 60.0
_blacklist_check_last_alert = 0.0
def _log_blacklist_check_alert(error: Exception) -> None:
"""节流记录黑名单检查失败告警,避免 Redis 故障期间每个请求刷一条 ERROR"""
global _blacklist_check_last_alert
now = time.monotonic()
if now - _blacklist_check_last_alert >= _BLACKLIST_ALERT_INTERVAL:
_blacklist_check_last_alert = now
logger.error(
"Token黑名单检查异常,已按 fail-open 放行(已注销的令牌可能被误放行): %s", error)
else:
logger.debug("Token黑名单检查异常(节流中,跳过 ERROR): %s", error)
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 django_redis import get_redis_connection
redis = get_redis_connection("default")
# 获取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', ex=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 django_redis import get_redis_connection
redis = get_redis_connection("default")
fingerprint = _token_fingerprint(token)
key = f'{_TOKEN_BLACKLIST_PREFIX}{fingerprint}'
return redis.exists(key) > 0
except Exception as e:
# fail-open 权衡:Redis 不可用时放行,避免全部请求被拒;代价是已注销的令牌可能被放行。
# 属安全降级,需告警;节流记录避免 Redis 故障期间日志刷屏。
_log_blacklist_check_alert(e)
return False
def check_token_blacklist(token: str) -> bool:
"""
检查Token是否在黑名单中(用于中间件)
Args:
token: JWT令牌字符串
Returns:
bool: True-在黑名单中(应拒绝),False-不在黑名单中(可继续验证)
"""
return is_token_blacklisted(token)
+75
View File
@@ -0,0 +1,75 @@
# +======================================================================
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
# +----------------------------------------------------------------------
# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有
# +----------------------------------------------------------------------
# | 开源协议: Apache-2.0
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
# +----------------------------------------------------------------------
# | 官方网站: https://www.djangoadmin.cn
# +----------------------------------------------------------------------
# | 研发团队: 小蚂蚁云团队 荣誉出品
# +======================================================================
# | 版权和免责声明:
# | 一、知识产权声明
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
# | 二、使用限制与禁止条款
# | 您理解并同意,禁止将本产品用于以下任何行为:
# | 1. 任何违反中华人民共和国法律法规的用途;
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
# | 三、责任豁免
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
# | 四、违约与法律责任
# | 如发现任何违反本声明的行为,本团队有权:
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
# | 3. 保留进一步采取刑事控告等法律措施的权利。
# | 五、许可范围说明
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
# | 成的一切经济损失及商誉损害。
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
# | 许可协议》附件为准。
# +======================================================================
# +======================================================================
# | 模块: 密码加密工具
# | 说明: 基于 bcrypt 的双重加盐方案
# +======================================================================
"""密码加密工具:基于 bcrypt 的双重加盐方案。"""
import secrets
import bcrypt
# bcrypt 工作因子,值越大计算越慢越安全,默认12(约250ms)
BCRYPT_ROUNDS = 12
# ============================================================
# 密码工具函数
# ============================================================
def generate_salt(length: int = 10) -> str:
"""生成随机盐值"""
return secrets.token_urlsafe(length)[:length]
def encrypt_password(password: str, salt: str) -> str:
"""使用 bcrypt 加密密码(外部 salt 双重加盐)"""
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:
"""验证密码是否正确"""
combined = (password + salt).encode('utf-8')
return bcrypt.checkpw(combined, hashed.encode('utf-8'))
+117
View File
@@ -0,0 +1,117 @@
# +======================================================================
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
# +----------------------------------------------------------------------
# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有
# +----------------------------------------------------------------------
# | 开源协议: Apache-2.0
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
# +----------------------------------------------------------------------
# | 官方网站: https://www.djangoadmin.cn
# +----------------------------------------------------------------------
# | 研发团队: 小蚂蚁云团队 荣誉出品
# +======================================================================
# | 版权和免责声明:
# | 一、知识产权声明
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
# | 二、使用限制与禁止条款
# | 您理解并同意,禁止将本产品用于以下任何行为:
# | 1. 任何违反中华人民共和国法律法规的用途;
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
# | 三、责任豁免
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
# | 四、违约与法律责任
# | 如发现任何违反本声明的行为,本团队有权:
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
# | 3. 保留进一步采取刑事控告等法律措施的权利。
# | 五、许可范围说明
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
# | 成的一切经济损失及商誉损害。
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
# | 许可协议》附件为准。
# +======================================================================
"""
表单错误处理工具模块
=============================================================
该模块提供统一的表单验证错误信息提取功能。
用于处理Django表单验证失败时的错误信息,将复杂的错误数据结构
转换为简洁易读的字符串格式,便于前端展示和日志记录。
主要功能:
1. 提取表单验证错误信息
2. 将错误列表转换为字符串格式
3. 支持多字段错误信息的合并展示
所属包: utils
所在文件: utils/form_errors.py
依赖模块: django.forms - Django表单模块
"""
def get_err(form):
"""
获取表单错误文本
=============================================================
从Django表单对象中提取所有错误信息,并将其合并为单个字符串。
主要用于表单验证失败时,向前端返回统一的错误提示。
参数:
form (django.forms.Form): Django表单实例,包含验证后的错误信息
返回:
str: 合并后的错误信息字符串,多个错误用'/'分隔
如果没有错误,返回空字符串
使用示例:
>>> form = UserForm(request.POST)
>>> if not form.is_valid():
... error_msg = get_err(form)
... return R.failed(msg=error_msg)
处理流程:
1. 从form.errors中获取JSON格式的错误数据
2. 遍历每个字段的错误信息
3. 提取每个错误的具体消息内容
4. 将所有错误消息合并为字符串
错误格式说明:
form.errors.get_json_data() 返回格式示例:
{
'username': [{'message': '用户名不能为空', 'code': 'required'}],
'password': [{'message': '密码长度至少6位', 'code': 'min_length'}]
}
"""
# 初始化错误列表
# 说明:用于收集所有字段的错误消息
error_list = []
# 遍历所有字段的错误信息
# 方法:form.errors.get_json_data()
# 说明:获取表单错误的JSON表示,包含每个字段的所有错误详情
for item in form.errors.get_json_data().values():
# 提取错误消息
# 说明:每个字段可能有多个错误,取第一个错误的message字段
# item[0]:获取该字段的第一个错误对象
# get('message'):从错误对象中获取具体的错误消息文本
error_list.append(item[0].get('message'))
# 合并错误字符串
# 方法:'/'.join(error_list)
# 说明:将所有错误消息用'/'连接成一个字符串
# 例如:['用户名不能为空', '密码不能为空'] -> '用户名不能为空/密码不能为空'
err_str = '/'.join(error_list)
# 返回合并后的错误字符串
return err_str
+149
View File
@@ -0,0 +1,149 @@
# +======================================================================
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
# +----------------------------------------------------------------------
# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有
# +----------------------------------------------------------------------
# | 开源协议: Apache-2.0
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
# +----------------------------------------------------------------------
# | 官方网站: https://www.djangoadmin.cn
# +----------------------------------------------------------------------
# | 研发团队: 小蚂蚁云团队 荣誉出品
# +======================================================================
# | 版权和免责声明:
# | 一、知识产权声明
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
# | 二、使用限制与禁止条款
# | 您理解并同意,禁止将本产品用于以下任何行为:
# | 1. 任何违反中华人民共和国法律法规的用途;
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
# | 三、责任豁免
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
# | 四、违约与法律责任
# | 如发现任何违反本声明的行为,本团队有权:
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
# | 3. 保留进一步采取刑事控告等法律措施的权利。
# | 五、许可范围说明
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
# | 成的一切经济损失及商誉损害。
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
# | 许可协议》附件为准。
# +======================================================================
"""
用户安全工具模块
=============================================================
该模块提供了用户认证和安全相关的工具函数。
用于快速获取当前登录用户的信息,简化业务代码中的用户信息获取操作。
"""
import logging
from utils.jwt import parse_token
# ============================= 用户信息获取工具 =============================
def get_user_id(request):
"""
获取当前登录用户ID
=============================================================
快速获取当前登录用户的ID,用于需要用户ID的业务场景。
基于parse_token函数实现,简化调用方式。
使用场景:
- 记录操作日志时需要记录操作人ID
- 数据查询时进行用户权限过滤
- 创建数据时自动关联当前用户
参数:
request (HttpRequest): Django请求对象,包含请求头中的token信息
返回:
int: 当前登录用户的ID,若token无效或用户不存在则返回0
异常:
可能抛出parse_token函数产生的异常(如token过期、token无效等)
建议调用方自行处理异常
示例:
>>> user_id = get_user_id(request)
>>> 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(request):
"""
获取当前登录用户名称
=============================================================
快速获取当前登录用户的真实姓名,用于显示用户信息。
基于parse_token函数实现,简化调用方式。
使用场景:
- 在页面中显示当前登录用户名称
- 记录操作日志时显示操作人姓名
- 发送通知时获取用户名称
参数:
request (HttpRequest): Django请求对象,包含请求头中的token信息
返回:
str: 当前登录用户的真实姓名(realname),若token无效或用户不存在则返回空字符串
异常:
可能抛出parse_token函数产生的异常(如token过期、token无效等)
建议调用方自行处理异常
示例:
>>> username = get_username(request)
>>> if username:
... print(f"当前用户: {username}")
... else:
... print("未登录或token无效")
"""
try:
# 解析请求中的token,获取用户信息
result = parse_token(request)
# 安全获取用户真实姓名,避免KeyError和None值
if result and isinstance(result, dict):
data = result.get('data', {})
if isinstance(data, dict):
username = data.get('realname')
# 如果realname为None或空字符串,返回空字符串
return username if username else ''
return ''
except Exception as e:
# 记录异常日志(可根据需要添加日志记录)
logging.error(f"获取用户名失败: {str(e)}")
return ''