perf: 性能优化 - Nginx反向代理配置、图片OSS直连、自动压缩、上传限制20MB

This commit is contained in:
菜鸟生产 2026-03-18 19:16:20 +08:00
parent 990474f033
commit f3625fa196
6 changed files with 169 additions and 48 deletions

View File

@ -1,6 +1,7 @@
# 藏品路由 - 使用字段编码 # 藏品路由 - 使用字段编码
import os import os
import uuid import uuid
import re
from typing import Optional, List from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File
from sqlalchemy import func, text from sqlalchemy import func, text
@ -13,6 +14,7 @@ from app.schemas.schemas import (
CollectionCreate, CollectionUpdate, CollectionResponse, CollectionCreate, CollectionUpdate, CollectionResponse,
CollectionListResponse, CollectionImageResponse CollectionListResponse, CollectionImageResponse
) )
from app.services.oss import upload_to_oss, get_oss_path, delete_from_oss, get_public_url
router = APIRouter(prefix="/api/collections", tags=["藏品"]) router = APIRouter(prefix="/api/collections", tags=["藏品"])
@ -576,55 +578,46 @@ async def upload_image(
if file_size > 10 * 1024 * 1024: # 10MB if file_size > 10 * 1024 * 1024: # 10MB
raise HTTPException(status_code=400, detail=f"E00039: 图片大小不能超过 10MB当前{file_size // 1024 // 1024}MB") raise HTTPException(status_code=400, detail=f"E00039: 图片大小不能超过 10MB当前{file_size // 1024 // 1024}MB")
# 创建上传目录 # 生成OSS存储路径
upload_dir = "uploads/collections" user_id = collection.f99_91_user_id
os.makedirs(upload_dir, exist_ok=True)
# 生成文件名:用户名 - 藏品编号 - 冠字号.jpg
file_extension = file.filename.split('.')[-1] if '.' in file.filename else 'jpg' file_extension = file.filename.split('.')[-1] if '.' in file.filename else 'jpg'
# 清理特殊字符,只保留字母、数字、中文、横杠
import re # 清理特殊字符
clean_username = re.sub(r'[^\w\u4e00-\u9fff\-]', '', username) clean_username = re.sub(r'[^\w\u4e00-\u9fff\-]', '', username)
clean_serial = re.sub(r'[^\w\u4e00-\u9fff\-]', '', prefix_serial) clean_serial = re.sub(r'[^\w\u4e00-\u9fff\-]', '', prefix_serial)
# 文件名格式:用户名 - 藏品编号 - 冠字号 # 文件名格式:用户名-藏品编号-冠字号.jpg
if clean_serial: if clean_serial:
filename = f"{clean_username}-{code}-{clean_serial}.{file_extension}" filename = f"{clean_username}-{code}-{clean_serial}.{file_extension}"
else: else:
filename = f"{clean_username}-{code}.{file_extension}" filename = f"{clean_username}-{code}.{file_extension}"
# 如果文件已存在,添加时间戳避免覆盖 # 生成OSS key
file_path = os.path.join(upload_dir, filename) oss_key, unique_name = get_oss_path("collections", user_id=user_id, filename=filename)
if os.path.exists(file_path):
import time
timestamp = int(time.time())
base_name = filename.rsplit('.', 1)[0]
filename = f"{base_name}-{timestamp}.{file_extension}"
file_path = os.path.join(upload_dir, filename)
# 保存文件 # 上传到OSS
with open(file_path, "wb") as buffer: image_url = upload_to_oss(content, oss_key)
buffer.write(content)
# 创建图片记录 # 创建图片记录保存OSS URL
image = CollectionImage( image = CollectionImage(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
collection_id=collection_id, collection_id=collection_id,
filename=filename, filename=unique_name,
original_name=file.filename, original_name=file.filename,
path=file_path path=image_url # 保存OSS URL
) )
db.add(image) db.add(image)
db.commit() db.commit()
db.refresh(image) db.refresh(image)
logger.info(f"图片上传成功:{filename}, collection_id={collection_id}") logger.info(f"图片上传成功:{image_url}, collection_id={collection_id}")
return { return {
"message": "上传成功", "message": "上传成功",
"image_id": image.id, "image_id": image.id,
"filename": filename "filename": unique_name,
"url": image_url
} }
except HTTPException: except HTTPException:
raise raise
@ -657,8 +650,16 @@ async def delete_image(
if collection and current_user.role != "admin" and collection.f99_91_user_id != current_user.f99_90_id: if collection and current_user.role != "admin" and collection.f99_91_user_id != current_user.f99_90_id:
raise HTTPException(status_code=403, detail="E00014: 无权删除此图片") raise HTTPException(status_code=403, detail="E00014: 无权删除此图片")
# 删除文件 # 删除OSS文件如果path是OSS URL
if image.path and os.path.exists(image.path): if image.path and image.path.startswith("https://"):
# 从OSS URL提取key
try:
oss_key = image.path.replace("https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com/", "")
delete_from_oss(oss_key)
except Exception as e:
logger.warning(f"OSS文件删除失败: {e}")
elif image.path and os.path.exists(image.path):
# 兼容旧的本地上传
os.remove(image.path) os.remove(image.path)
# 删除数据库记录 # 删除数据库记录

View File

@ -59,18 +59,12 @@ def get_oss_path(file_type: str, user_id: str = None, collection_id: str = None,
return None return None
# 上传图片到OSS - 使用服务层(带压缩)
from app.services.oss import upload_to_oss as oss_upload
def upload_to_oss(file_data, oss_key): def upload_to_oss(file_data, oss_key):
"""上传文件到阿里云OSS""" """上传文件到阿里云OSS带自动压缩"""
import oss2 return oss_upload(file_data, oss_key)
auth = oss2.Auth(OSS_CONFIG["access_key_id"], OSS_CONFIG["access_key_secret"])
bucket = oss2.Bucket(auth, OSS_CONFIG["endpoint"], OSS_CONFIG["bucket_name"])
result = bucket.put_object(oss_key, file_data)
if result.status == 200:
return f"{OSS_CONFIG['public_url']}/{oss_key}"
else:
raise Exception(f"OSS上传失败: {result.status}")
# 专业提示词 # 专业提示词
PROFESSIONAL_PROMPT = """你是一名专业的人民币生肖纪念钞鉴定专家。请严格按照以下步骤分析这张图片,并提取准确信息。 PROFESSIONAL_PROMPT = """你是一名专业的人民币生肖纪念钞鉴定专家。请严格按照以下步骤分析这张图片,并提取准确信息。

126
backend/app/services/oss.py Normal file
View File

@ -0,0 +1,126 @@
# 阿里云OSS服务
import os
import uuid
import datetime
from typing import Optional
import oss2
from PIL import Image
import io
# OSS配置
OSS_CONFIG = {
"access_key_id": os.getenv("OSS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"),
"access_key_secret": os.getenv("OSS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"),
"bucket_name": "jiachenlong-oss",
"endpoint": "oss-cn-hangzhou.aliyuncs.com",
"public_url": "https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com"
}
# 图片压缩配置
IMAGE_CONFIG = {
"max_size": 1024 * 1024, # 1MB
"max_width": 2048,
"max_height": 2048,
"quality": 85,
"format": "JPEG"
}
# 初始化OSS
auth = oss2.Auth(OSS_CONFIG["access_key_id"], OSS_CONFIG["access_key_secret"])
bucket = oss2.Bucket(auth, OSS_CONFIG["endpoint"], OSS_CONFIG["bucket_name"])
def get_oss_path(prefix: str, user_id: str = None, filename: str = None) -> str:
"""生成OSS存储路径"""
now = datetime.datetime.now()
year = now.strftime("%Y")
month = now.strftime("%m")
day = now.strftime("%d")
if filename:
ext = filename.split('.')[-1] if '.' in filename else 'jpg'
unique_name = f"{uuid.uuid4().hex}.{ext}"
else:
unique_name = f"{uuid.uuid4().hex}.jpg"
if user_id:
path = f"{prefix}/{user_id}/{year}/{month}/{unique_name}"
else:
path = f"{prefix}/{year}/{month}/{day}/{unique_name}"
return path, unique_name
def upload_to_oss(file_data: bytes, oss_key: str, compress: bool = True) -> str:
"""上传文件到OSS返回公网URL"""
try:
# 如果是图片,进行压缩
if compress and any(oss_key.lower().endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp']):
file_data = compress_image(file_data)
# 上传文件
result = bucket.put_object(oss_key, file_data)
if result.status == 200:
# 返回公网URL
return f"{OSS_CONFIG['public_url']}/{oss_key}"
else:
raise Exception(f"OSS上传失败: {result.status}")
except Exception as e:
raise Exception(f"OSS上传失败: {str(e)}")
def delete_from_oss(oss_key: str) -> bool:
"""从OSS删除文件"""
try:
result = bucket.delete_object(oss_key)
return result.status == 204
except Exception as e:
print(f"OSS删除失败: {str(e)}")
return False
def get_public_url(oss_key: str) -> str:
"""获取公网URL"""
return f"{OSS_CONFIG['public_url']}/{oss_key}"
def compress_image(image_data: bytes, max_size: int = None) -> bytes:
"""压缩图片到指定大小以内"""
if max_size is None:
max_size = IMAGE_CONFIG["max_size"]
# 如果已经小于限制,直接返回
if len(image_data) <= max_size:
return image_data
# 打开图片
img = Image.open(io.BytesIO(image_data))
# 如果是PNG且有透明通道转换为RGB
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
# 逐步降低质量直到达到目标大小
quality = 95
compressed_data = image_data
while quality > 30 and len(compressed_data) > max_size:
output = io.BytesIO()
img.save(output, format=IMAGE_CONFIG["format"], quality=quality, optimize=True)
compressed_data = output.getvalue()
quality -= 10
# 如果还是太大,缩小尺寸
if len(compressed_data) > max_size:
width, height = img.size
while len(compressed_data) > max_size and width > 400:
width = int(width * 0.8)
height = int(height * 0.8)
img_resized = img.resize((width, height), Image.Resampling.LANCZOS)
output = io.BytesIO()
img_resized.save(output, format=IMAGE_CONFIG["format"], quality=80, optimize=True)
compressed_data = output.getvalue()
return compressed_data

View File

@ -2,7 +2,7 @@
# Version Configuration for Zodiac Collection Management System # Version Configuration for Zodiac Collection Management System
# 当前版本号 (语义化版本:主版本。次版本.修订版) # 当前版本号 (语义化版本:主版本。次版本.修订版)
VERSION=1.1.19 VERSION=1.1.21
# 版本代号 (可选) # 版本代号 (可选)
VERSION_CODENAME="新生" VERSION_CODENAME="新生"
@ -11,4 +11,4 @@ VERSION_CODENAME="新生"
RELEASE_DATE=2026-03-18 RELEASE_DATE=2026-03-18
# 版本说明 # 版本说明
VERSION_NOTES="个人设置页面 - 支持修改个人信息和密码" VERSION_NOTES="性能优化 - Nginx反向代理、图片OSS直连、自动压缩、上传限制20MB"

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<title>甲辰收藏 v1.1.18</title> <title>甲辰收藏 v1.1.19</title>
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/static/icons/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/static/icons/favicon.ico" />

View File

@ -95,16 +95,16 @@ export default function Home() {
border: '1px solid rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.1)',
backdropFilter: 'blur(10px)' backdropFilter: 'blur(10px)'
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<img src="/static/images/jiachenlong-logo.png" alt="甲辰收藏" style={{ width: '48px', height: '48px', borderRadius: '50%', objectFit: 'cover' }} /> <img src="/static/images/jiachenlong-logo.png" alt="甲辰收藏" style={{ width: '48px', height: '48px', borderRadius: '50%', objectFit: 'cover' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}> <div>
<div onClick={handleLogout} style={{ color: 'rgba(255,255,255,0.5)', cursor: 'pointer', fontSize: '12px', padding: '4px 8px', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '4px' }}>退出</div> <div style={{ color: '#fff', fontSize: '18px', fontWeight: '600' }}>{user?.username || '用户'}</div>
<div onClick={() => window.location.hash = '#/settings'} style={{ color: 'rgba(255,255,255,0.5)', cursor: 'pointer', fontSize: '12px', padding: '4px 8px', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '4px' }}>设置</div> <div style={{ color: 'rgba(255,255,255,0.6)', fontSize: '12px' }}>欢迎回来 👋</div>
</div>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ color: 'rgba(255,255,255,0.3)', fontSize: '10px' }}>v{APP_VERSION}</div> <div style={{ color: 'rgba(255,255,255,0.3)', fontSize: '10px' }}>v{APP_VERSION}</div>
<div> <div onClick={() => window.location.hash = '#/settings'} style={{ color: 'rgba(255,255,255,0.5)', cursor: 'pointer', fontSize: '12px', padding: '4px 8px', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '4px' }}>设置</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '600', textAlign: 'right' }}>{user?.username || '用户'}</div> <div onClick={handleLogout} style={{ color: 'rgba(255,255,255,0.5)', cursor: 'pointer', fontSize: '12px', padding: '4px 8px', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '4px' }}>退出</div>
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: '12px' }}>欢迎回来 👋</div>
</div>
</div> </div>
</div> </div>
</div> </div>