Skip to content

Commit 9602e28

Browse files
authored
Add local file upload interfaces (#489)
* Add the alibaba cloud oss plugin * Update oss plugin struct * Add local upload
1 parent 440f324 commit 9602e28

File tree

12 files changed

+159
-16
lines changed

12 files changed

+159
-16
lines changed

backend/app/admin/api/v1/sys/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from backend.app.admin.api.v1.sys.menu import router as menu_router
1111
from backend.app.admin.api.v1.sys.role import router as role_router
1212
from backend.app.admin.api.v1.sys.token import router as token_router
13+
from backend.app.admin.api.v1.sys.upload import router as upload_router
1314
from backend.app.admin.api.v1.sys.user import router as user_router
1415

1516
router = APIRouter(prefix='/sys')
@@ -23,3 +24,4 @@
2324
router.include_router(user_router, prefix='/users', tags=['系统用户'])
2425
router.include_router(data_rule_router, prefix='/data-rules', tags=['系统数据权限规则'])
2526
router.include_router(token_router, prefix='/tokens', tags=['系统令牌'])
27+
router.include_router(upload_router, prefix='/upload', tags=['系统上传'])
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
from typing import Annotated
5+
6+
from fastapi import APIRouter, File, UploadFile
7+
8+
from backend.common.dataclasses import UploadUrl
9+
from backend.common.enums import FileType
10+
from backend.common.response.response_schema import ResponseSchemaModel, response_base
11+
from backend.common.security.jwt import DependsJwtAuth
12+
from backend.utils.file_ops import file_verify, upload_file
13+
14+
router = APIRouter()
15+
16+
17+
@router.post('/image', summary='上传图片', dependencies=[DependsJwtAuth])
18+
async def upload_image(file: Annotated[UploadFile, File()]) -> ResponseSchemaModel[UploadUrl]:
19+
file_verify(file, FileType.image)
20+
filename = await upload_file(file)
21+
return response_base.success(data={'url': f'/static/upload/{filename}'})
22+
23+
24+
@router.post('/video', summary='上传视频', dependencies=[DependsJwtAuth])
25+
async def upload_video(file: Annotated[UploadFile, File()]) -> ResponseSchemaModel[UploadUrl]:
26+
file_verify(file, FileType.video)
27+
filename = await upload_file(file)
28+
return response_base.success(data={'url': f'/static/upload/{filename}'})

backend/app/generator/conf.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,12 @@
22
# -*- coding: utf-8 -*-
33
from functools import lru_cache
44

5-
from pydantic_settings import BaseSettings, SettingsConfigDict
6-
7-
from backend.core.path_conf import BasePath
5+
from pydantic_settings import BaseSettings
86

97

108
class GeneratorSettings(BaseSettings):
119
"""Admin Settings"""
1210

13-
model_config = SettingsConfigDict(env_file=f'{BasePath}/.env', env_file_encoding='utf-8', extra='ignore')
14-
1511
# 模版目录
1612
TEMPLATE_BACKEND_DIR_NAME: str = 'py'
1713

backend/common/dataclasses.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,8 @@ class TokenPayload:
5959
id: int
6060
session_uuid: str
6161
expire_time: datetime
62+
63+
64+
@dataclasses.dataclass
65+
class UploadUrl:
66+
url: str

backend/common/enums.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ class UserSocialType(StrEnum):
103103
linuxdo = 'LinuxDo'
104104

105105

106+
class FileType(StrEnum):
107+
"""文件类型"""
108+
109+
image = 'image'
110+
video = 'video'
111+
112+
106113
class GenModelMySQLColumnType(StrEnum):
107114
"""代码生成模型列类型(MySQL)"""
108115

backend/core/conf.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ class Settings(BaseSettings):
4848
FASTAPI_OPENAPI_URL: str | None = '/openapi'
4949
FASTAPI_STATIC_FILES: bool = True
5050

51+
# Upload
52+
UPLOAD_READ_SIZE: int = 1024 # 上传文件时分片读取大小
53+
UPLOAD_IMAGE_EXT_INCLUDE: list[str] = ['jpg', 'jpeg', 'png', 'gif', 'webp']
54+
UPLOAD_IMAGE_SIZE_MAX: int = 1024 * 1024 * 5
55+
UPLOAD_VIDEO_EXT_INCLUDE: list[str] = ['mp4', 'mov', 'avi', 'flv']
56+
UPLOAD_VIDEO_SIZE_MAX: int = 1024 * 1024 * 20
57+
5158
# Database
5259
DATABASE_ECHO: bool = False
5360
DATABASE_SCHEMA: str = 'fba'
@@ -114,7 +121,6 @@ class Settings(BaseSettings):
114121
CORS_ALLOWED_ORIGINS: list[str] = [
115122
'http://127.0.0.1:8000',
116123
'http://localhost:5173', # 前端地址,末尾不要带 '/'
117-
'http://localhost:63342',
118124
]
119125
CORS_EXPOSE_HEADERS: list[str] = [
120126
TRACE_ID_REQUEST_HEADER_KEY,

backend/core/path_conf.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717
# 离线 IP 数据库路径
1818
IP2REGION_XDB = os.path.join(BasePath, 'static', 'ip2region.xdb')
1919

20-
# 挂载静态目录
20+
# 静态资源目录
2121
STATIC_DIR = os.path.join(BasePath, 'static')
2222

23+
# 上传文件目录
24+
UPLOAD_DIR = os.path.join(BasePath, 'static', 'upload')
25+
2326
# jinja2 模版文件路径
2427
JINJA2_TEMPLATE_DIR = os.path.join(BasePath, 'templates')
2528

backend/core/registrar.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#!/usr/bin/env python3
22
# -*- coding: utf-8 -*-
3+
import os
4+
35
from contextlib import asynccontextmanager
46

57
import socketio
@@ -9,11 +11,12 @@
911
from fastapi_limiter import FastAPILimiter
1012
from fastapi_pagination import add_pagination
1113
from starlette.middleware.authentication import AuthenticationMiddleware
14+
from starlette.staticfiles import StaticFiles
1215

1316
from backend.common.exception.exception_handler import register_exception
1417
from backend.common.log import set_customize_logfile, setup_logging
1518
from backend.core.conf import settings
16-
from backend.core.path_conf import STATIC_DIR
19+
from backend.core.path_conf import STATIC_DIR, UPLOAD_DIR
1720
from backend.database.db import create_table
1821
from backend.database.redis import redis_client
1922
from backend.middleware.jwt_auth_middleware import JwtAuthMiddleware
@@ -101,14 +104,17 @@ def register_logger() -> None:
101104

102105
def register_static_file(app: FastAPI):
103106
"""
104-
静态文件交互开发模式, 生产将自动关闭,生产必须使用 nginx 静态资源服务
107+
静态资源服务,生产应使用 nginx 代理静态资源服务
105108
106109
:param app:
107110
:return:
108111
"""
112+
# 上传静态资源
113+
if not os.path.exists(UPLOAD_DIR):
114+
os.makedirs(UPLOAD_DIR)
115+
app.mount('/static/upload', StaticFiles(directory=UPLOAD_DIR), name='upload')
116+
# 固有静态资源
109117
if settings.FASTAPI_STATIC_FILES:
110-
from fastapi.staticfiles import StaticFiles
111-
112118
app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static')
113119

114120

backend/plugin/tools.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,18 @@ async def install_requirements_async() -> None:
169169
if not os.path.exists(requirements_file):
170170
continue
171171
else:
172-
await async_subprocess.create_subprocess_exec(sys.executable, '-m', 'ensurepip', '--upgrade')
173-
res = await async_subprocess.create_subprocess_exec(
172+
ensurepip_process = await async_subprocess.create_subprocess_exec(
173+
sys.executable,
174+
'-m',
175+
'ensurepip',
176+
'--upgrade',
177+
stdout=asyncio.subprocess.PIPE,
178+
stderr=asyncio.subprocess.PIPE,
179+
)
180+
_, ensurepip_stderr = await ensurepip_process.communicate()
181+
if ensurepip_process.returncode != 0:
182+
raise PluginInjectError(f'ensurepip 安装失败:{ensurepip_stderr}')
183+
pip_process = await async_subprocess.create_subprocess_exec(
174184
sys.executable,
175185
'-m',
176186
'pip',
@@ -180,6 +190,6 @@ async def install_requirements_async() -> None:
180190
stdout=asyncio.subprocess.PIPE,
181191
stderr=asyncio.subprocess.PIPE,
182192
)
183-
_, stderr = await res.communicate()
184-
if res.returncode != 0:
185-
raise PluginInjectError(f'插件 {plugin} 依赖包安装失败:{stderr}')
193+
_, pip_stderr = await pip_process.communicate()
194+
if pip_process.returncode != 0:
195+
raise PluginInjectError(f'插件 {plugin} 依赖包安装失败:{pip_stderr}')

backend/utils/file_ops.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
import os
4+
5+
import aiofiles
6+
7+
from fastapi import UploadFile
8+
9+
from backend.common.enums import FileType
10+
from backend.common.exception import errors
11+
from backend.common.log import log
12+
from backend.core.conf import settings
13+
from backend.core.path_conf import UPLOAD_DIR
14+
from backend.utils.timezone import timezone
15+
16+
17+
def build_filename(file: UploadFile):
18+
"""
19+
构建文件名
20+
21+
:param file:
22+
:return:
23+
"""
24+
timestamp = int(timezone.now().timestamp())
25+
filename = file.filename
26+
file_ext = filename.split('.')[-1].lower()
27+
new_filename = f'{filename.replace(f".{file_ext}", f"_{timestamp}")}.{file_ext}'
28+
return new_filename
29+
30+
31+
def file_verify(file: UploadFile, file_type: FileType) -> None:
32+
"""
33+
文件验证
34+
35+
:param file:
36+
:param file_type:
37+
:return:
38+
"""
39+
filename = file.filename
40+
file_ext = filename.split('.')[-1].lower()
41+
if not file_ext:
42+
raise errors.ForbiddenError(msg='未知的文件类型')
43+
if file_type == FileType.image:
44+
if file_ext not in settings.UPLOAD_IMAGE_EXT_INCLUDE:
45+
raise errors.ForbiddenError(msg='此图片格式暂不支持')
46+
if file.size > settings.UPLOAD_IMAGE_SIZE_MAX:
47+
raise errors.ForbiddenError(msg='图片超出最大限制,请重新选择')
48+
elif file_type == FileType.video:
49+
if file_ext not in settings.UPLOAD_VIDEO_EXT_INCLUDE:
50+
raise errors.ForbiddenError(msg='此视频格式暂不支持')
51+
if file.size > settings.UPLOAD_VIDEO_SIZE_MAX:
52+
raise errors.ForbiddenError(msg='视频超出最大限制,请重新选择')
53+
54+
55+
async def upload_file(file: UploadFile):
56+
"""
57+
上传文件
58+
59+
:param file:
60+
:return:
61+
"""
62+
filename = build_filename(file)
63+
try:
64+
async with aiofiles.open(os.path.join(UPLOAD_DIR, filename), mode='wb') as fb:
65+
while True:
66+
content = await file.read(settings.UPLOAD_READ_SIZE)
67+
if not content:
68+
break
69+
await fb.write(content)
70+
except Exception as e:
71+
log.error(f'上传文件 {filename} 失败:{str(e)}')
72+
raise errors.RequestError(msg='上传文件失败')
73+
finally:
74+
await file.close()
75+
return filename

deploy/backend/docker-compose/docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ services:
8181
volumes:
8282
- ../nginx.conf:/etc/nginx/conf.d/default.conf:ro
8383
- fba_static:/www/fba_server/backend/static
84+
- fba_static_upload:/www/fba_server/backend/static/upload
8485
networks:
8586
- fba_network
8687

deploy/backend/nginx.conf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,8 @@ server {
4848
location /static {
4949
alias /www/fba_server/backend/static;
5050
}
51+
52+
location /static/upload {
53+
alias /www/fba_server/backend/static/upload;
54+
}
5155
}

0 commit comments

Comments
 (0)