签入版本

This commit is contained in:
2026-09-12 23:12:15 +08:00
commit 91998056ca
1841 changed files with 341325 additions and 0 deletions
@@ -0,0 +1,198 @@
# 数据库与 ORM
<!-- Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 -->
:::tip 说明
项目使用 Django ORM 作为数据库访问层,数据库连接配置位于 `config/env.py` 和 `application/settings.py`。默认使用 MySQL 数据库,通过 `django.db.backends.mysql` 引擎连接。
:::
## 数据库配置
### 环境变量(config/env.py)
```python
# 数据库引擎,默认 MySQL
DATABASE_ENGINE = os.getenv('DATABASE_ENGINE', "django.db.backends.mysql")
# 数据库名称
DATABASE_NAME = os.getenv('DATABASE_NAME', 'djangoadmin.django.elevue')
# 数据库地址
DATABASE_HOST = os.getenv('DATABASE_HOST', "127.0.0.1")
# 数据库端口
DATABASE_PORT = os.getenv('DATABASE_PORT', 3306)
# 数据库用户名
DATABASE_USER = os.getenv('DATABASE_USER', 'root')
# 数据库密码
DATABASE_PASSWORD = os.getenv('DATABASE_PASSWORD', 'root')
# 数据表前缀
DATABASE_PREFIX = os.getenv('DATABASE_PREFIX', "django_")
```
### Django Settings(application/settings.py)
```python
DATABASES = {
"default": {
"ENGINE": env.DATABASE_ENGINE,
"NAME": env.DATABASE_NAME,
"USER": env.DATABASE_USER,
"PASSWORD": env.DATABASE_PASSWORD,
"HOST": env.DATABASE_HOST,
"PORT": env.DATABASE_PORT,
}
}
```
## MySQL 引擎配置
项目默认使用 `django.db.backends.mysql` 引擎,需要安装 `mysqlclient` 或 `PyMySQL`:
```bash
# 方式一:mysqlclient(推荐,C 扩展,性能更好)
pip install mysqlclient
# 方式二:PyMySQL(纯 Python,兼容性更好)
pip install PyMySQL
```
如果使用 PyMySQL,需要在 `application/__init__.py` 中添加:
```python
import pymysql
pymysql.install_as_MySQLdb()
```
## 数据表前缀
所有业务数据表自动添加 `DATABASE_PREFIX` 前缀(默认 `django_`),便于多应用共存:
```python
# utils/common.py
def get_table_name(base_name):
"""获取带前缀的表名"""
prefix = getattr(settings, 'DATABASE_PREFIX', '')
return f"{prefix}{base_name}"
```
## ORM 查询模式
### 基础查询
```python
from application.example.models import Example
# 查询未删除的记录(软删除)
queryset = Example.objects.filter(is_delete=False)
# 条件过滤
queryset = queryset.filter(name__contains=name) # 模糊查询
queryset = queryset.filter(status=status) # 精确匹配
queryset = queryset.order_by("sort") # 排序
```
### 分页查询
```python
from django.core.paginator import Paginator, PageNotAnInteger, InvalidPage, EmptyPage
paginator = Paginator(queryset, page_size)
try:
page_list = paginator.page(page_no)
except (PageNotAnInteger, InvalidPage, EmptyPage):
page_list = paginator.page(1)
# 构建分页数据
page_data = {
'records': [item for item in page_list],
'total': paginator.count,
'size': page_size,
'current': page_no,
'pages': paginator.num_pages,
}
```
### 创建记录
```python
Example.objects.create(
name="示例",
status=1,
create_user=get_username(request),
update_user=get_username(request),
)
```
### 更新记录
```python
instance = Example.objects.filter(id=instance_id, is_delete=False).first()
if instance:
instance.name = "新名称"
instance.update_user = get_username(request)
instance.save()
```
### 批量更新(逻辑删除)
```python
Example.objects.filter(id__in=id_list, is_delete=False).update(is_delete=True)
```
## 软删除机制
所有业务模型继承 `BaseModel`,包含 `is_delete` 字段。所有查询都过滤 `is_delete=False`,删除操作为逻辑删除:
```python
# application/models.py
class BaseModel(models.Model):
id = models.BigAutoField(primary_key=True)
create_user = models.CharField(max_length=64, default='', null=True, blank=True)
create_time = models.DateTimeField(auto_now_add=True)
update_user = models.CharField(max_length=64, default='', null=True, blank=True)
update_time = models.DateTimeField(auto_now=True)
is_delete = models.BooleanField(default=False)
class Meta:
abstract = True
```
## 缓存配置(Redis)
Redis 用于 JWT Token 黑名单和权限缓存:
```python
# application/settings.py
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": f"redis://{env.REDIS_HOST}:{env.REDIS_PORT}/{env.REDIS_DB}",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"PASSWORD": env.REDIS_PASSWORD if env.REDIS_PASSWORD else None,
"SOCKET_CONNECT_TIMEOUT": 5,
"SOCKET_TIMEOUT": 5,
"CONNECTION_POOL_KWARGS": {"max_connections": 100},
"IGNORE_EXCEPTIONS": True,
},
"KEY_PREFIX": "djangoadmin",
}
}
```
Redis 配置(config/env.py):
```python
REDIS_HOST = os.getenv('REDIS_HOST', '127.0.0.1')
REDIS_PORT = int(os.getenv('REDIS_PORT', 6379))
REDIS_PASSWORD = os.getenv('REDIS_PASSWORD', '123456')
REDIS_DB = int(os.getenv('REDIS_DB', 0))
```
## 总结
数据库会话管理由 Django ORM 自动处理,每个请求自动管理数据库连接的生命周期。通过 `config/env.py` 集中管理数据库和 Redis 连接配置,支持通过环境变量覆盖。软删除机制确保数据安全,所有查询自动过滤已删除记录。
@@ -0,0 +1,127 @@
# 导入导出
<!-- Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 -->
:::tip 说明
基于 `openpyxl` 实现 Excel 导入导出功能,支持通过 `file_template` 模块管理导入模板配置。
:::
## 功能概述
Excel 导入导出是后台管理系统的常见需求,通过 `openpyxl` 库实现:
```
Excel 导出:
1. 将数据库中的数据导出为 Excel 文件,支持自定义表头和格式
2. 适用于生成报表、数据备份等场景
Excel 导入:
1. 将 Excel 文件中的数据导入到数据库,支持批量处理和校验
2. 适用于批量数据录入、数据迁移等场景
```
## 依赖说明
在 `requirements.txt` 中已包含:
```
openpyxl>=3.1.0
```
## 导出实现
导出接口通常在各业务模块的 views.py 中定义,返回 `HttpResponse`:
```python
from openpyxl import Workbook
from django.http import HttpResponse
import io
class ExportView(PermissionRequired, View):
permission_required = ("sys:user:export",)
def get(self, request):
# 查询数据
data_list = services.get_export_data(request)
# 创建工作簿
wb = Workbook()
ws = wb.active
ws.title = "用户信息"
# 写入表头
ws.append(["用户名", "姓名", "手机号", "邮箱", "状态"])
# 写入数据
for item in data_list:
ws.append([item.username, item.realname, item.phone, item.email, item.status_name])
# 返回流式响应
output = io.BytesIO()
wb.save(output)
output.seek(0)
response = HttpResponse(
output.getvalue(),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
response["Content-Disposition"] = "attachment; filename=export.xlsx"
return response
```
## 导入实现
导入接口接收上传的 Excel 文件,解析后逐行校验并入库:
```python
class ImportView(PermissionRequired, View):
permission_required = ("sys:user:import",)
def post(self, request):
file = request.FILES.get('file')
if not file:
return R.failed(msg="请选择要上传的文件")
wb = load_workbook(io.BytesIO(file.read()))
ws = wb.active
# 跳过表头,逐行解析
for row in ws.iter_rows(min_row=2, values_only=True):
username, realname, phone, email, status = row
# 校验并入库 ...
```
## file_template 模块
`file_template` 模块用于管理导入模板的配置信息,定义模板的字段映射、校验规则等。
模块路径:`application/file_template/`
```
模板配置包含:
- 模板名称
- 关联模块
- 字段映射关系
- 校验规则
```
:::tip 温馨提示
导入时建议先下载模板文件,按模板格式填写数据后再导入,可减少格式错误。
:::
## 错误处理
导入过程中遇到校验错误时,返回详细的错误信息:
```json
{
"code": 1,
"msg": "导入失败:第3行用户名已存在,第5行手机号格式错误",
"data": null,
"ok": false
}
```
## 总结
Excel 导入导出功能基于 `openpyxl` 实现,导出使用 `HttpResponse` 流式返回避免内存溢出,导入逐行校验并提供详细错误信息。`file_template` 模块可管理导入模板配置,便于维护字段映射和校验规则。
@@ -0,0 +1,249 @@
# IP/UA 解析
<!-- Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 -->
:::tip 说明
提供客户端 IP 获取、IP 地理位置查询和 User-Agent 解析三大能力,位于 `utils/common.py`。三者组合使用,为登录日志、操作日志等场景提供客户端身份信息。
:::
## 架构总览
```
请求进入
│
▼
┌──────────────────────────────────────────────────────┐
│ get_client_ip(request) │
│ ├─ HTTP_X_FORWARDED_FOR 存在? │
│ │ └─ 是 → 取第一个 IP(客户端真实 IP) │
│ └─ 否 → REMOTE_ADDR(直接连接 IP) │
└──────────┬───────────────────────────────────────────┘
│
┌─────┴─────┐
▼ ▼
┌─────────┐ ┌──────────────────────────────────┐
│ 日志记录 │ │ get_ip_location(ip) │
│ 操作日志 │ │ ├─ 内网 IP → "内网IP" │
└─────────┘ │ ├─ ip-api.com 在线查询 │
│ └─ 异常降级 → "未知" │
└──────────┬───────────────────────┘
│
▼
┌──────────────────────┐
│ parse_user_agent(ua) │
│ ├─ 操作系统(正则) │
│ └─ 浏览器(正则) │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ 写入日志表 │
│ ip / location / │
│ os / browser │
└──────────────────────┘
```
## 一、IP 获取
### get_client_ip(request)
```python
# utils/common.py
def get_client_ip(request) -> str:
"""
获取客户端真实IP地址
优先从 HTTP_X_FORWARDED_FOR 获取,其次使用 REMOTE_ADDR。
"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
```
**返回值**:客户端 IP 地址字符串(如 `"1.2.3.4"`)。
### 代理环境说明
当应用部署在 Nginx 等反向代理之后,`REMOTE_ADDR` 拿到的是代理 IP 而非真实客户端 IP。此时需要从 `X-Forwarded-For`(XFF)头提取真实 IP。
```
客户端 (1.2.3.4)
│
▼
Nginx 代理 (10.0.0.1) ← 追加 XFF: 1.2.3.4
│
▼
Django 应用
REMOTE_ADDR = 10.0.0.1
HTTP_X_FORWARDED_FOR = "1.2.3.4"
```
:::warning 安全提示
如果应用被直接暴露在公网,客户端可伪造 XFF 首部。生产环境务必通过 Nginx 等反向代理转发请求。
:::
## 二、IP 地理位置查询
### get_ip_location(ip)
通过第三方 API 获取 IP 地理位置:
```python
# utils/common.py
def get_ip_location(ip: str) -> str:
"""
根据IP获取地理位置
内网IP直接返回"内网IP",调用 ip-api.com API 获取地理位置。
"""
# 内网IP判断
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 "未知"
```
**返回值示例**:
| IP | 返回值 |
|:----|:----|
| `114.114.114.114` | `中国 江苏省 南京市` |
| `8.8.8.8` | `United States California` |
| `10.0.0.1` | `内网IP` |
| `172.abc` | `未知` |
### IP 地理位置配置
在 `config/env.py` 中配置 IP 查询相关参数:
```python
# IP地理位置信息来源: 'local' 或 'api'
IP_LOCATION_SOURCE = os.environ.get('IP_LOCATION_SOURCE', 'api')
# 本地IP数据库文件路径
IP_DATABASE_PATH = os.environ.get('IP_DATABASE_PATH', ...)
# IP查询API超时时间(秒)
IP_API_TIMEOUT = int(os.environ.get('IP_API_TIMEOUT', 5))
# 是否启用IP地理位置缓存
IP_CACHE_ENABLED = os.environ.get('IP_CACHE_ENABLED', 'True') == 'True'
# IP缓存时间(秒),默认1天
IP_CACHE_TIMEOUT = int(os.environ.get('IP_CACHE_TIMEOUT', 86400))
```
### 内网 IP 判断
内网/保留 IP 直接返回 `"内网IP"`,不查询 API:
| 类型 | 匹配规则 | 示例 |
|:-----|:---------|:-----|
| 回环地址 | `127.0.0.1`、`localhost` | `127.0.0.1` |
| C 类私有 | `192.168.x.x` | `192.168.1.1` |
| A 类私有 | `10.x.x.x` | `10.0.0.1` |
## 三、UA 解析
### parse_user_agent(user_agent)
通过正则匹配从 User-Agent 字符串提取操作系统和浏览器信息:
```python
# utils/common.py
def parse_user_agent(user_agent: str) -> dict:
"""
解析User-Agent,获取操作系统和浏览器信息
Returns:
dict: {'os': '操作系统', 'browser': '浏览器'}
"""
```
**使用示例**:
```python
ua = request.META.get('HTTP_USER_AGENT', '')
info = parse_user_agent(ua)
print(f"操作系统: {info['os']}, 浏览器: {info['browser']}")
```
### 支持的操作系统
| 操作系统 | UA 关键字 | 输出 |
|:---------|:---------|:-----|
| Windows 10 | `Windows NT 10.0` | `Windows 10` |
| Windows 7 | `Windows NT 6.1` | `Windows 7` |
| macOS | `Mac OS X` | `macOS` |
| Linux | `Linux` | `Linux` |
| Android | `Android` | `Android` |
| iOS | `iPhone` / `iPad` | `iOS` |
### 支持的浏览器
按匹配优先级排列(Edge 优先于 Chrome):
| 浏览器 | UA 关键字 | 输出 |
|:-------|:---------|:-----|
| Edge | `Edg` | `Edge` |
| Chrome | `Chrome` | `Chrome` |
| Safari | `Safari` | `Safari` |
| Firefox | `Firefox` | `Firefox` |
| IE | `MSIE` / `Trident` | `IE` |
## 四、应用场景
### 登录日志
登录/退出时自动记录 IP、地理位置、操作系统、浏览器:
```python
ip = get_client_ip(request)
location = get_ip_location(ip)
ua = request.META.get('HTTP_USER_AGENT', '')
info = parse_user_agent(ua)
```
### 操作日志
写操作(add/update/delete/status)通过 `@operation_log` 装饰器自动记录操作者 IP 和设备信息。
## 五、常见问题
### 开发环境日志中 IP 显示为 127.0.0.1
**原因**:本地直连无代理,`REMOTE_ADDR` 就是 `127.0.0.1`。
**说明**:这是正常行为。生产环境配置 Nginx 反向代理后即可获取真实客户端 IP。
### 地理位置显示为"未知"
**可能原因**:
1. ip-api.com API 请求超时(免费版限制 45 次/分钟)
2. 网络连接问题
3. 畸形 IP 地址
**解决**:生产环境建议使用本地 IP 数据库(如纯真 IP 库),配置 `IP_LOCATION_SOURCE=local`。
## 六、总结
| 模块 | 文件 | 核心能力 | 降级策略 |
|:-----|:-----|:---------|:---------|
| IP 获取 | `utils/common.py` | 从 Request 提取真实客户端 IP | 优先 XFF,回退 REMOTE_ADDR |
| 地理位置 | `utils/common.py` | 基于 ip-api.com 在线查询 | 异常/内网统一降级返回 `"未知"` 或 `"内网IP"` |
| UA 解析 | `utils/common.py` | 正则匹配提取操作系统和浏览器 | UA 为空时返回 `{'os': '未知', 'browser': '未知'}` |
三者组合为登录日志、操作日志等场景提供完整的客户端身份信息链路。所有接口均采用降级策略,保证日志落库等核心流程不受异常影响。
@@ -0,0 +1,146 @@
# 日志系统
<!-- Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 -->
:::tip 说明
通过 Python 标准库 `logging` 实现结构化日志,Django 框架内置日志配置,支持控制台 + 文件双输出。各模块通过 `logging.getLogger(__name__)` 获取 logger 实例。
:::
## Django 日志配置
在 `application/settings.py` 中可配置 `LOGGING` 字典(项目当前使用默认配置):
```python
# application/settings.py 中可添加自定义日志配置
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{asctime} | {levelname:<7} | {name} | {message}',
'style': '{',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
'file': {
'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/app.log',
'maxBytes': 10 * 1024 * 1024, # 10MB
'backupCount': 5,
'formatter': 'verbose',
'encoding': 'utf-8',
},
},
'root': {
'handlers': ['console', 'file'],
'level': 'INFO',
},
}
```
## 使用方式
在各模块中获取 logger:
```python
import logging
logger = logging.getLogger(__name__)
logger.info("操作成功")
logger.warning("配置缺失")
logger.error(f"异常信息: {str(e)}")
```
## 日志格式
```
2025-03-06 14:30:25,123 | INFO | application.example.services | 操作成功
```
格式说明:
| 字段 | 说明 |
|------|------|
| `asctime` | 时间戳 |
| `levelname` | 日志级别(右对齐7字符) |
| `name` | 模块名称 |
| `message` | 日志内容 |
## 日志级别
| 级别 | 数值 | 说明 |
|------|------|------|
| `DEBUG` | 10 | 调试信息,生产环境通常关闭 |
| `INFO` | 20 | 一般信息,记录正常操作 |
| `WARNING` | 30 | 警告信息,不影响运行但需关注 |
| `ERROR` | 40 | 错误信息,功能异常 |
| `CRITICAL` | 50 | 严重错误,系统不可用 |
## 业务模块中的日志使用
### Service 层
```python
# application/example/services.py
import logging
logger = logging.getLogger(__name__)
def get_example_page(request):
try:
# 业务逻辑 ...
return R.ok(data=page_data)
except Exception as e:
logging.error(f"查询案例分页数据异常: {str(e)}")
return R.failed(msg="查询失败,请稍后重试")
```
### 视图层
```python
# application/upload/views.py
import logging
logging.info(f"上传文件的字段名称: {field_name}")
```
## 操作日志
除了应用日志外,系统通过 `@operation_log` 装饰器自动记录用户的写操作日志,存储到数据库中。详见 [操作日志](/zh/volume4/core/operation-log) 章节。
```python
from application.operation_log.decorators import operation_log
from application.operation_log.constants import LogType
class ExampleAddView(PermissionRequired, View):
@operation_log(title="案例管理-添加记录", log_type=LogType.ADD)
def post(self, request):
result = services.add_example(request)
return result
```
## 日志轮转
当日志文件达到指定大小时自动轮转:
```
logs/app.log ← 当前日志
logs/app.log.1 ← 最近一次轮转
logs/app.log.2 ← 倒数第二次
logs/app.log.3
logs/app.log.4
logs/app.log.5 ← 最早的备份(超过自动删除)
```
:::tip 温馨提示
生产环境建议配置日志轮转,防止日志文件无限增长撑爆磁盘。`RotatingFileHandler` 按文件大小轮转,`TimedRotatingFileHandler` 按时间轮转。
:::
## 总结
日志系统基于 Python 标准库 `logging`,通过 Django 的 `LOGGING` 配置统一管理。各模块通过 `logging.getLogger(__name__)` 获取 logger 实例,支持控制台和文件双输出。操作日志通过 `@operation_log` 装饰器自动记录到数据库。
@@ -0,0 +1,193 @@
# 密码工具
<!-- Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 -->
:::tip 说明
密码加密使用 Django 内置的 `make_password` 和 `check_password` 函数,基于 PBKDF2 算法(默认工作因子 320000 次迭代)。用户安全信息获取工具位于 `utils/security.py`。
:::
## 加密方案
Django 默认使用 PBKDF2 + SHA256 算法进行密码哈希,内置随机盐值管理:
```
用户密码 + 随机盐 → PBKDF2(SHA256, 320000轮) → 存储哈希值
```
### 加密密码
```python
from django.contrib.auth.hashers import make_password
# 生成密码哈希(自动管理盐值)
hashed = make_password("mypassword")
# 结果格式:pbkdf2_sha256$320000$随机盐$哈希值
```
### 验证密码
```python
from django.contrib.auth.hashers import check_password
# 验证密码是否正确
is_correct = check_password("mypassword", hashed)
# 返回 True 或 False
```
## 存储结构
Django 密码哈希格式:`algorithm$iterations$salt$hash`
```
pbkdf2_sha256$320000$abc123def456$longhashvalue...
```
| 部分 | 说明 |
|------|------|
| `pbkdf2_sha256` | 哈希算法 |
| `320000` | 迭代次数(工作因子) |
| `abc123def456` | 随机盐值 |
| `longhashvalue...` | 哈希结果 |
## 用户安全工具(utils/security.py)
### get_user_id(request)
快速获取当前登录用户的 ID:
```python
from utils.security import get_user_id
def some_view(request):
user_id = get_user_id(request)
if user_id:
# 执行需要用户ID的操作
create_log(user_id, "操作内容")
```
### get_username(request)
快速获取当前登录用户的真实姓名:
```python
from utils.security import get_username
def add_example(request):
Example.objects.create(
name="示例",
create_user=get_username(request),
update_user=get_username(request),
)
```
## JWT Token 工具(utils/jwt.py)
### create_token(payload, timeout)
生成 JWT 访问令牌:
```python
from utils.jwt import create_token
payload = {'userId': 1001, 'username': 'admin', 'realname': '管理员'}
token = create_token(payload, timeout=20) # 20分钟过期
```
### parse_token(request)
从请求头解析 JWT Token,获取用户信息:
```python
from utils.jwt import parse_token
result = parse_token(request)
if result['code'] == 0:
user_id = result['data']['userId']
username = result['data']['username']
realname = result['data']['realname']
```
### refresh_token(token, extend_minutes)
刷新 JWT 令牌,延长有效期:
```python
from utils.jwt import refresh_token
result = refresh_token(old_token, extend_minutes=30)
if result['code'] == 0:
new_token = result['data']['access_token']
```
### Token 黑名单
退出登录时将 Token 加入黑名单,使其立即失效:
```python
from utils.jwt import add_token_to_blacklist
# 退出登录时调用
add_token_to_blacklist(token)
```
黑名单使用 Redis 存储,TTL 与 Token 剩余有效期一致,Token 自然过期后黑名单条目自动清除。
## 使用示例
### 登录验证
```python
from django.contrib.auth.hashers import check_password
from utils.jwt import create_token
def login(request):
data, error = parse_request_body(request)
username = data.get('username')
password = data.get('password')
# 查询用户
user = User.objects.filter(username=username, is_delete=False).first()
if not user:
return R.failed(msg="用户名或密码错误")
# 验证密码
if not check_password(password, user.password):
return R.failed(msg="用户名或密码错误")
# 生成 Token
payload = {
'userId': user.id,
'username': user.username,
'realname': user.realname,
}
token = create_token(payload)
return R.ok(data={'access_token': token})
```
### 修改密码
```python
from django.contrib.auth.hashers import make_password, check_password
def reset_password(request):
# 验证旧密码
if not check_password(old_password, user.password):
return R.failed("原密码错误")
# 生成新密码哈希
user.password = make_password(new_password)
user.save()
```
## 安全特性
```
1. 随机盐值:Django 自动为每个密码生成随机盐值,即使两个用户使用相同密码,存储的哈希值也不同
2. 抗彩虹表:每个密码都有独立盐值,无法预计算彩虹表
3. 抗暴力破解:PBKDF2 320000 次迭代,大幅增加暴力破解成本
4. 自适应性:可通过修改迭代次数提升安全性
```
## 总结
密码工具使用 Django 内置的 `make_password`/`check_password`,基于 PBKDF2 + SHA256 算法,自动管理随机盐值。`utils/security.py` 提供 `get_user_id()` 和 `get_username()` 快速获取当前登录用户信息。JWT Token 通过 `utils/jwt.py` 管理,支持生成、解析、刷新和黑名单功能。
@@ -0,0 +1,194 @@
# 权限缓存
<!-- Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 -->
:::tip 说明
通过 Redis 实现用户权限和菜单的缓存,采用用户级缓存 + 全局版本号双层策略。权限查询函数位于 `application/menu/services.py`,供 `PermissionRequired` 中间件调用。
:::
## 缓存策略
采用「用户级缓存 + 全局版本号」双层策略:
```
1. 每个用户的缓存 Key 带版本号后缀
2. 版本号存全局 Key(perm:version)
3. 菜单变更时递增全局版本号,所有用户缓存自然失效
4. 用户角色变更时,直接删除该用户缓存
5. 角色菜单变更时,查询该角色下所有用户 ID,批量删除缓存
```
## 缓存 Key 结构
```python
_VERSION_KEY = 'perm:version' # 全局版本号
_PERM_PREFIX = 'perm:list:' # 用户权限列表
_MENU_PREFIX = 'perm:menu:' # 用户菜单树
```
Key 格式示例:
```
perm:version → 5
perm:list:1001:5 → ["sys:user:page", "sys:user:add", ...]
perm:menu:1001:5 → [{id: 1, name: "系统管理", children: [...]}]
```
## Redis 连接配置
Redis 通过 Django 缓存框架 `django_redis` 管理:
```python
# application/settings.py
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": f"redis://{env.REDIS_HOST}:{env.REDIS_PORT}/{env.REDIS_DB}",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"PASSWORD": env.REDIS_PASSWORD if env.REDIS_PASSWORD else None,
"CONNECTION_POOL_KWARGS": {"max_connections": 100},
"IGNORE_EXCEPTIONS": True,
},
"KEY_PREFIX": "djangoadmin",
}
}
```
获取 Redis 连接:
```python
from django_redis import get_redis_connection
redis = get_redis_connection("default")
```
## 权限查询
### get_user_permissions(user_id)
获取用户的权限列表,优先从缓存读取:
```python
def get_user_permissions(user_id):
"""获取用户权限列表,带缓存"""
redis_conn = get_redis_connection("default")
ver = redis_conn.get(_VERSION_KEY) or 0
cache_key = f'{_PERM_PREFIX}{user_id}:{ver}'
# 尝试从缓存获取
cached = redis_conn.get(cache_key)
if cached:
return json.loads(cached)
# 缓存未命中,查询数据库
permissions = _query_permissions_from_db(user_id)
# 写入缓存(TTL 2小时)
if permissions:
redis_conn.set(cache_key, json.dumps(permissions), ex=7200)
return permissions
```
## 缓存失效
### 菜单变更
菜单增删改时调用,递增全局版本号使所有用户缓存失效:
```python
def invalidate_menu_change():
"""菜单变更时递增全局版本号"""
redis_conn = get_redis_connection("default")
redis_conn.incr(_VERSION_KEY)
```
递增版本号后,旧版本的缓存 Key 不再被访问,自然过期(TTL 2小时)。
### 用户角色变更
用户角色变更时调用,删除该用户的权限和菜单缓存:
```python
def invalidate_user_perms(user_id):
"""用户角色变更时删除缓存"""
redis_conn = get_redis_connection("default")
ver = redis_conn.get(_VERSION_KEY) or 0
keys = [
f'{_PERM_PREFIX}{user_id}:{ver}',
f'{_MENU_PREFIX}{user_id}:{ver}',
]
redis_conn.delete(*keys)
```
### 角色菜单变更
角色菜单变更时调用,清理该角色下所有用户的权限和菜单缓存:
```python
def invalidate_role_perms(role_id):
"""角色菜单变更时清理相关用户缓存"""
# 查询该角色下所有用户 ID
from application.user_role.models import UserRole
user_ids = UserRole.objects.filter(
role_id=role_id, is_delete=False
).values_list('user_id', flat=True)
# 批量删除缓存
redis_conn = get_redis_connection("default")
ver = redis_conn.get(_VERSION_KEY) or 0
keys = []
for uid in user_ids:
keys.append(f'{_PERM_PREFIX}{uid}:{ver}')
keys.append(f'{_MENU_PREFIX}{uid}:{ver}')
if keys:
redis_conn.delete(*keys)
```
## PermissionRequired 中间件
权限校验在 `middleware/permission_middleware.py` 中实现:
```python
class PermissionRequired:
"""权限校验中间件,用户 ID=1 为超级管理员跳过校验"""
def dispatch(self, request, *args, **kwargs):
# 超级管理员跳过权限检查
user_id = get_user_id(request)
if user_id == 1:
return super().dispatch(request, *args, **kwargs)
# 获取用户权限列表
permissions = get_user_permissions(user_id)
# 检查是否包含所需权限
for perm in self.permission_required:
if perm not in permissions:
return R.failed(msg="暂无操作权限", code=403)
return super().dispatch(request, *args, **kwargs)
```
## 异常降级
所有缓存操作都包裹 try/except,Redis 不可用时降级直查 DB:
```python
try:
cached = redis_conn.get(cache_key)
if cached:
return json.loads(cached)
except Exception as e:
logger.warning(f"读取权限缓存异常: {e}")
# 降级查询数据库
```
:::tip 温馨提示
权限缓存的 TTL 为 2小时,正常情况下缓存由业务操作即时失效,TTL 仅作为兜底防止 Redis 故障时缓存永久残留。
:::
## 总结
权限缓存采用用户级缓存 + 全局版本号双层策略,菜单变更通过递增版本号使所有缓存自然失效,无需遍历删除。所有缓存操作都有异常降级机制,Redis 不可用时自动回源数据库查询。超级管理员(用户 ID=1)跳过权限检查。
@@ -0,0 +1,168 @@
# 请求解析工具
<!-- Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 -->
:::tip 说明
`utils/common.py` 提供请求数据解析相关的工具函数,核心函数 `parse_request_body()` 用于解析请求体 JSON 数据。这些工具函数在各业务模块中广泛使用。
:::
## parse_request_body
解析请求体 JSON 数据,返回 `(data, error)` 元组。这是业务模块中最常用的请求解析函数。
```python
# utils/common.py
def parse_request_body(request):
"""
解析请求体JSON数据
Args:
request: HttpRequest对象
Returns:
tuple: (data, error)
成功时 data 为解析后的字典,error 为 None
失败时 data 为 None,error 为错误描述字符串
"""
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, "参数不能为空"
data = json.loads(json_data)
return data, None
except json.JSONDecodeError as e:
return None, "参数格式错误"
except UnicodeDecodeError as e:
return None, "参数编码错误"
except Exception as e:
return None, "参数处理失败"
```
使用示例:
```python
def add_example(request):
# 解析请求参数
data, error = parse_request_body(request)
if error:
return R.failed(msg=error)
# 使用解析后的数据
name = data.get('name')
status = data.get('status')
# ...
```
:::tip 温馨提示
`parse_request_body()` 返回元组 `(data, error)`,成功时 `data` 为字典、`error` 为 None;失败时 `data` 为 None、`error` 为错误描述字符串。调用方应先检查 `error` 再使用 `data`。
:::
## GET 参数获取
Django 中获取 GET 查询参数使用 `request.GET`:
```python
def get_example_page(request):
# 获取分页参数
page_no = int(request.GET.get('pageNo', 1))
page_size = int(request.GET.get('pageSize', PAGE_SIZE))
# 获取筛选参数
name = request.GET.get('name')
status = request.GET.get('status')
if name:
queryset = queryset.filter(name__contains=name)
if status:
queryset = queryset.filter(status=status)
```
## POST 表单参数
获取 POST 表单数据使用 `request.POST`:
```python
def upload_file(request):
# 获取上传的文件
file = request.FILES.get('file')
# 获取表单字段
field_name = request.POST.get('name', '')
```
## 文件上传参数
获取上传文件使用 `request.FILES`:
```python
file = request.FILES.get('file')
if file:
original_name = file.name # 原始文件名
file_size = file.size # 文件大小
# 分块读取文件内容
for chunk in file.chunks():
f.write(chunk)
```
## 表单错误处理
`utils/regular.py` 中的 `get_err()` 函数用于提取 Django 表单验证错误信息:
```python
# utils/regular.py
def get_err(form):
"""
获取表单错误文本,多个错误用'/'分隔
使用示例:
form = UserForm(data)
if not form.is_valid():
error_msg = get_err(form)
return R.failed(msg=error_msg)
"""
error_list = []
for item in form.errors.get_json_data().values():
error_list.append(item[0].get('message'))
return '/'.join(error_list)
```
## 参数格式化(日志用)
`format_params()` 函数将请求参数格式化为 JSON 字符串,自动过滤敏感字段:
```python
# utils/common.py
def format_params(params):
"""
格式化请求参数,自动过滤密码等敏感字段
敏感字段值替换为 '******'
"""
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)
```
## 日期格式化
```python
# utils/common.py
def format_datetime(dt, format_str='%Y-%m-%d %H:%M:%S'):
"""格式化日期时间对象为字符串"""
return dt.strftime(format_str) if dt else None
```
## 总结
请求解析工具函数覆盖了 JSON 请求体解析、GET/POST 参数获取、文件上传、表单验证错误提取等常见场景。`parse_request_body()` 是最核心的函数,返回 `(data, error)` 元组,调用方应先检查错误再使用数据。`get_err()` 将 Django 表单验证错误转换为可读字符串。
@@ -0,0 +1,221 @@
# 富文本过滤
<!-- Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 -->
:::tip 说明
富文本编辑器内容存在存储型 XSS 风险,通过 `save_content()` 函数实现 HTML 内容处理,在落盘前统一过滤危险内容并迁移嵌入图片。源码位于 `utils/common.py`,涉及富文本的模块(文章、通知公告等)均调用 `save_content()` 处理。
:::
## 安全威胁
富文本编辑器允许用户输入 HTML 内容,攻击者可能注入:
```
1. <script> 标签 —— 执行任意 JavaScript
2. on* 事件属性 —— 如 onerror="alert(1)"
3. javascript: 协议 —— 如 <a href="javascript:alert(1)">
4. <iframe> 标签 —— 嵌入外部页面
5. <style> 标签 —— 覆盖页面样式
```
### 攻击示例
```html
<!-- 恶意脚本注入 -->
<script>alert('XSS')</script>
<!-- 事件属性注入 -->
<img src="x" onerror="alert('XSS')">
<!-- 协议注入 -->
<a href="javascript:alert('XSS')">点击我</a>
<!-- iframe 注入 -->
<iframe src="javascript:alert('XSS')"></iframe>
```
## save_content() 函数
`save_content()` 函数处理富文本内容,提取并迁移嵌入图片:
```python
# utils/common.py
def save_content(content, title, directory):
"""
保存富文本编辑器内容
处理流程:
1. 使用正则表达式提取所有 img 标签的 src 属性
2. 遍历每个图片 URL,调用 save_file 保存
3. 将原 URL 替换为新 URL(带 [IMG_URL] 标记)
4. 设置空 alt 为文章标题
"""
# 提取所有图片URL
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)
# 设置alt标题(有利于SEO)
title = str(title) if title else ""
if content.find("alt=\"\"") and title:
content = content.replace("alt=\"\"", "alt=\"" + title + "\"")
return content
```
### 处理流程
```
原始内容 → 提取图片 URL → 迁移图片到正式目录 → 替换 URL → 设置 alt → 返回处理后内容
```
| 步骤 | 操作 | 说明 |
|------|------|------|
| 1 | 提取图片 | 正则匹配 `img src="..."` |
| 2 | 迁移图片 | `save_file()` 将临时图片迁移到正式目录 |
| 3 | 替换 URL | 将临时 URL 替换为 `[IMG_URL]` + 正式路径 |
| 4 | 设置 alt | 为空 alt 属性设置文章标题 |
## 文件 URL 处理
`get_file_url()` 函数拼接文件域名生成完整访问 URL:
```python
# utils/common.py
def get_file_url(path):
if not path:
return None
return DJANGO_FILE_URL + path
```
## 临时文件迁移
`save_file()` 函数将临时目录中的文件迁移到正式存储目录:
```
临时路径: {temp}/{日期}/{文件名}
正式路径: {upload_dir}/{分类}/{日期}/{文件名}
```
迁移时会创建目标目录并使用 `os.rename()` 移动文件。
## 使用示例
在业务模块中处理富文本内容:
```python
from utils.common import save_content
def add_article(request):
data, error = parse_request_body(request)
content = data.get('content')
title = data.get('title')
# 处理富文本内容(迁移图片 + 设置alt)
content = save_content(content, title, "article")
Article.objects.create(
title=title,
content=content,
# ...
)
```
### 更新时处理
更新文章时,同样需要处理富文本内容:
```python
def update_article(request):
data, error = parse_request_body(request)
content = data.get('content')
title = data.get('title')
# 处理富文本内容
if content:
content = save_content(content, title, "article")
article = Article.objects.filter(id=article_id, is_delete=False).first()
article.content = content
article.save()
```
## 富文本编辑器集成
项目前端使用 `@wangeditor/editor-for-vue` 富文本编辑器:
```vue
<template>
<div class="editor-container">
<Toolbar :editor="editorRef" :defaultConfig="toolbarConfig" />
<Editor
v-model="content"
:defaultConfig="editorConfig"
@onCreated="handleCreated"
/>
</div>
</template>
<script setup>
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import '@wangeditor/editor/dist/css/style.css';
const editorRef = ref(null);
const content = ref('');
const toolbarConfig = {
excludeKeys: ['fullScreen', 'group-video'], // 排除不需要的功能
};
const editorConfig = {
placeholder: '请输入内容...',
MENU_CONF: {
uploadImage: {
server: '/api/upload/uploadFile',
fieldName: 'file',
maxFileSize: 10 * 1024 * 1024, // 10MB
allowedFileTypes: ['image/*'],
}
}
};
function handleCreated(editor) {
editorRef.value = editor;
}
</script>
```
:::warning 编辑器销毁
组件销毁时需要手动销毁编辑器实例,避免内存泄漏:
```vue
<script setup>
onBeforeUnmount(() => {
const editor = editorRef.value;
if (editor) editor.destroy();
});
</script>
```
:::
## XSS 防护建议
```
1. 后端过滤:save_content() 处理图片迁移和 alt 设置
2. 前端转义:渲染富文本内容时使用 v-html 需谨慎
3. CSP 策略:配置 Content-Security-Policy 响应头
4. 输入验证:限制富文本内容长度和格式
5. 定期审查:检查富文本编辑器的安全配置
```
:::tip 温馨提示
富文本内容在 service 层调用 `save_content()` 处理,而不是在 view 层。所有涉及富文本的模块(文章、通知公告等)都应使用此函数处理内容。
:::
## 总结
富文本过滤通过 `save_content()` 函数处理,自动提取并迁移内容中的嵌入图片,替换为正式存储路径。同时为没有 alt 属性的图片设置文章标题,有利于 SEO。前端使用 `@wangeditor/editor-for-vue` 编辑器,支持图片上传和格式化。所有涉及富文本的业务模块都应在 service 层调用此函数处理内容。
@@ -0,0 +1,223 @@
# 文件上传完整流程
<!-- Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 -->
本章从配置到使用,完整说明文件上传功能的实现方式,包括后端接口、前端组件、安全机制和最佳实践。
## 上传架构
```
前端 Vue3 后端 Django 文件系统
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Upload │ multipart │ 安全校验 │ 写入 │ uploads/ │
│ 组件 │ ─────────> │ ─ 扩展名白名单│ ───────> │ temp/ │
│ │ │ ─ MIME 校验 │ │ 日期/ │
│ │ │ ─ 大小校验 │ │ xxx.jpg│
└──────────┘ └──────────────┘ └──────────┘
│
▼
┌──────────────┐
│ 返回 fileUrl │
│ 前端存入字段 │
└──────────────┘
```
## 环境变量配置
在 `config/env.py` 中配置:
| 变量 | 默认值 | 说明 |
|:----|:----|:----|
| `DJANGO_UPLOAD_DIR` | `{项目根目录}/public/uploads` | 上传文件存储目录 |
| `DJANGO_FILE_URL` | `http://file.django.elevue` | 文件访问域名 |
| `DJANGO_TEMP_PATH` | `{DJANGO_UPLOAD_DIR}/temp` | 临时文件目录 |
## 上传接口
系统提供统一的文件上传入口,支持图片、文档、压缩包等各类文件:
### 上传文件
```
POST /upload/uploadFile
Content-Type: multipart/form-data
Authorization: Bearer <token>
file: <文件>
```
支持格式:
```
图片:.jpeg, .jpg, .png, .gif, .bmp
文档:.pdf, .doc, .docx, .xls, .xlsx, .ppt, .pptx, .txt
```
成功响应:
```json
{
"code": 0,
"msg": "上传成功",
"data": {
"originalName": "photo.jpg",
"fileExtension": "jpg",
"fileType": "image/jpeg",
"fileSize": 306995,
"fileName": "2025030614302512345.jpg",
"filePath": "/temp/20250306/2025030614302512345.jpg",
"fileUrl": "http://file.django.elevue/temp/20250306/2025030614302512345.jpg"
},
"ok": true
}
```
:::tip 富文本编辑器集成
富文本编辑器(如 WangEditor 等)的图片上传同样使用此接口。编辑器配置上传地址为 `/upload/uploadFile`,上传成功后从 `response.data.fileUrl` 获取图片 URL 即可。
:::
## 安全机制
### 扩展名白名单
只允许指定的扩展名格式上传,拒绝危险格式(如 `.exe`、`.bat`、`.sh`、`.js` 等)。
### MIME 类型校验
通过 `mimetypes` 模块推断文件的 MIME 类型,确保文件内容与扩展名一致。
### 临时目录隔离
上传的文件首先存储在 `temp` 临时目录,业务数据提交时才迁移到正式目录。未提交的临时文件可定期清理。
## 文件存储策略
### 目录结构
```
public/uploads/
├── temp/ # 临时文件目录
│ ├── 20260905/
│ │ ├── 2026090514302512345.jpg
│ │ └── 2026090515011278901.pdf
│ └── 20260906/
├── example/ # 正式文件目录(按业务分类)
│ ├── 20260905/
│ │ └── 2026090514302512345.jpg
│ └── 20260906/
└── article/
└── 20260906/
```
- 按日期分目录存储(`{日期}/`),避免单目录文件过多
- 文件名使用 `年月日时分秒 + 5位随机数` 重命名,避免中文文件名和冲突
- 保留原始扩展名
### 文件访问 URL
上传成功后返回的 `fileUrl` 是拼接了 `DJANGO_FILE_URL` 前缀的完整 URL:
```
http://file.django.elevue/temp/20250306/2025030614302512345.jpg
```
Service 层的 `get_file_url()` 会自动为文件字段补此前缀。
## 前端集成
### 文件上传组件
```vue
<template>
<el-upload
:action="uploadUrl"
:headers="headers"
:on-success="handleSuccess"
:before-upload="beforeUpload"
>
<el-button type="primary">上传文件</el-button>
</el-upload>
</template>
<script setup>
import { ref } from 'vue';
import { useUserStore } from '@/store/modules/user';
const uploadUrl = '/api/upload/uploadFile';
const userStore = useUserStore();
const headers = {
Authorization: `Bearer ${userStore.token}`,
};
function beforeUpload(file) {
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isLt10M) {
ElMessage.error('文件大小不能超过 10MB');
}
return isLt10M;
}
function handleSuccess(response) {
if (response.code === 0) {
// 保存 fileUrl 到业务字段
form.value.cover = response.data.fileUrl;
}
}
</script>
```
## 业务模块中的文件字段
在 Service 中使用 `save_file()` 处理文件迁移:
```python
# application/example/services.py
from utils.common import save_file, get_file_url
def add_example(request):
data, error = parse_request_body(request)
# ...
avatar = cleaned_data.get('avatar')
if avatar:
avatar = save_file(avatar, "example") # 迁移到正式目录
Example.objects.create(avatar=avatar, ...)
```
### 自动处理逻辑
1. **新增/更新时**:检测文件字段值是否为临时路径,如果是则迁移到正式目录
2. **列表/详情时**:通过 `get_file_url()` 自动为文件字段补全 `DJANGO_FILE_URL` 前缀
## 富文本中的图片
使用 `save_content()` 处理富文本中的图片:
```python
# utils/common.py
def save_content(content, title, directory):
"""提取富文本中的图片并迁移,替换 URL"""
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)
return content
```
## 常见问题
### 上传报演示环境无权限
**原因**:演示模式下写操作被拦截。
**解决**:将 `.env` 中的 `DJANGO_DEMO` 设为 `False`。
### 图片上传成功但不显示
**原因**:`DJANGO_FILE_URL` 配置错误或前端未拼接域名。
**解决**:检查 `config/env.py` 中的 `DJANGO_FILE_URL` 配置,确保前端正确拼接。
## 总结
文件上传功能通过扩展名白名单 + MIME 类型校验 + 临时目录隔离等安全机制保障安全性。文件按日期分目录存储,使用时间戳 + 随机数重命名。统一的 `uploadFile` 接口支持图片和各类文件上传。Service 层通过 `save_file()` 和 `save_content()` 处理文件迁移和 URL 拼接。
@@ -0,0 +1,136 @@
# 文件上传
<!-- Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 -->
:::tip 说明
提供本地文件上传功能,支持图片和通用文件上传,内置扩展名白名单校验。服务层位于 `application/upload/services.py`,视图层位于 `application/upload/views.py`。
:::
## 功能概述
文件上传模块提供统一的文件上传接口 `upload_file`,支持图片、文档、压缩包等各类文件,通过扩展名列表控制允许上传的格式。
```
POST /upload/uploadFile —— 统一文件上传入口
```
## 配置项
在 `config/env.py` 中配置上传相关参数:
```python
# 上传文件存储根目录
DJANGO_UPLOAD_DIR = os.getenv('DJANGO_UPLOAD_DIR', os.path.join(DJANGO_ROOT_PATH, '/public/uploads'))
# 临时文件路径
DJANGO_TEMP_PATH = DJANGO_UPLOAD_DIR + '/temp'
# 文件访问域名
DJANGO_FILE_URL = os.getenv('DJANGO_FILE_URL', 'http://file.django.elevue')
```
:::tip 温馨提示
`DJANGO_FILE_URL` 用于拼接完整的文件访问 URL,生产环境需配置为实际的文件服务域名或 Nginx 代理地址。
:::
## 文件命名规则
上传文件自动重命名,格式为:`年月日时分秒 + 5位随机数 + 扩展名`
```python
# application/upload/services.py
timestamp = time.strftime('%Y%m%d%H%M%S')
random_num = f'{random.randint(0, 99999):05d}'
generated_name = f"{timestamp}{random_num}{file_ext_with_dot}"
```
文件按日期分目录存储:`{UPLOAD_DIR}/temp/{YYYYMMDD}/{文件名}`
## 接口调用
### 上传文件
```
POST /upload/uploadFile
Content-Type: multipart/form-data
Authorization: Bearer <token>
参数: file(文件字段)
```
响应示例:
```json
{
"code": 0,
"msg": "上传成功",
"data": {
"originalName": "photo.jpg",
"fileExtension": "jpg",
"fileType": "image/jpeg",
"fileSize": 306995,
"fileName": "2025030614302512345.jpg",
"filePath": "/temp/20250306/2025030614302512345.jpg",
"fileUrl": "http://file.django.elevue/temp/20250306/2025030614302512345.jpg"
},
"ok": true
}
```
## 安全机制
### 扩展名白名单
文件上传服务对扩展名进行白名单校验,支持的格式包括:
```
图片:.jpeg, .jpg, .png, .gif, .bmp
文档:.doc, .docx, .xls, .xlsx, .ppt, .pptx, .pdf, .txt
```
### 文件类型校验
通过 `mimetypes` 模块推断文件的 MIME 类型,确保文件内容与扩展名一致。
### 路径安全
文件存储在临时目录中,业务提交时通过 `save_file()` 迁移到正式目录,避免路径遍历风险。
## 临时文件迁移
上传的文件首先存储在 `temp` 目录,业务数据提交时调用 `save_file()` 迁移到正式目录:
```python
# utils/common.py
def save_file(url, directory):
"""将临时文件迁移到正式存储目录"""
if url.find(DJANGO_FILE_URL) != -1:
if url.find("/temp") != -1:
# 构建目标目录路径
path = DJANGO_UPLOAD_DIR + "/" + directory + "/" + time.strftime('%Y%m%d')
mkdir(path)
# 移动文件
old_path = url.replace(DJANGO_FILE_URL, DJANGO_UPLOAD_DIR)
new_path = DJANGO_UPLOAD_DIR + "/" + directory + old_path.replace(DJANGO_TEMP_PATH, "")
os.rename(old_path, new_path)
return new_path.replace(DJANGO_UPLOAD_DIR, "")
```
## 文件 URL 拼接
`get_file_url()` 函数将相对路径拼接为完整的文件访问 URL:
```python
# utils/common.py
def get_file_url(path):
if not path:
return None
return DJANGO_FILE_URL + path
```
## 前端集成
前端使用 `Upload` 组件上传,上传成功后将返回的 `fileUrl` 存入业务字段。详见 [文件上传联调](/zh/volume4/integration/upload-sync) 章节。
## 总结
文件上传模块通过扩展名白名单、MIME 类型校验、临时目录隔离等安全机制,确保上传功能的安全性。文件自动重命名并按日期分目录存储,避免文件名冲突和信息泄露。统一的 `uploadFile` 接口同时支持图片和各类文件上传。