perf: 性能优化 - Nginx反向代理配置、图片OSS直连、自动压缩、上传限制20MB
This commit is contained in:
parent
990474f033
commit
f3625fa196
|
|
@ -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)
|
||||||
|
|
||||||
# 删除数据库记录
|
# 删除数据库记录
|
||||||
|
|
|
||||||
|
|
@ -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 = """你是一名专业的人民币生肖纪念钞鉴定专家。请严格按照以下步骤分析这张图片,并提取准确信息。
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue