fix: harden uploads downloads and deployment config
This commit is contained in:
@@ -12,6 +12,26 @@ from app.schemas.order import OrderCreate, OrderResponse, PaymentResponse
|
||||
|
||||
router = APIRouter(prefix="/orders", tags=["订单"])
|
||||
|
||||
|
||||
def _generate_order_no(db: Session) -> str:
|
||||
"""生成带随机后缀的唯一订单号,避免同秒冲突。"""
|
||||
now = datetime.now()
|
||||
six_months_ago = now - timedelta(days=180)
|
||||
date_part = six_months_ago.strftime('%Y%m%d')
|
||||
time_part = now.strftime('%H%M%S')
|
||||
|
||||
for _ in range(5):
|
||||
suffix = secrets.token_hex(3).upper()
|
||||
order_no = f"ORD{date_part}{time_part}{suffix}"
|
||||
exists = db.query(Order.id).filter(Order.order_no == order_no).first()
|
||||
if not exists:
|
||||
return order_no
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="生成订单号失败,请稍后重试",
|
||||
)
|
||||
|
||||
def get_current_user(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
"""获取当前登录用户"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
@@ -66,12 +86,8 @@ def create_order(
|
||||
detail="您已购买过此作品"
|
||||
)
|
||||
|
||||
# 生成订单号:前缀 + (当前时间-6个月)的年月日 + 当前时间的时分秒
|
||||
now = datetime.now()
|
||||
six_months_ago = now - timedelta(days=180) # 半年前
|
||||
date_part = six_months_ago.strftime('%Y%m%d') # 半年前的年月日
|
||||
time_part = now.strftime('%H%M%S') # 当前时间的时分秒
|
||||
order_no = f"ORD{date_part}{time_part}"
|
||||
# 生成唯一订单号:半年前日期 + 当前时分秒 + 随机后缀
|
||||
order_no = _generate_order_no(db)
|
||||
|
||||
# 创建订单
|
||||
new_order = Order(
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Header
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Header, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc, or_
|
||||
from typing import Optional
|
||||
import os
|
||||
import uuid
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from PIL import Image
|
||||
import json
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.work import Work
|
||||
from app.models.user import User
|
||||
@@ -51,6 +50,15 @@ def get_current_user(authorization: str = Header(None), db: Session = Depends(ge
|
||||
return user
|
||||
|
||||
|
||||
def _user_designer_aliases(user: User) -> list[str]:
|
||||
aliases = []
|
||||
for value in (getattr(user, "nickname", None), getattr(user, "phone", None)):
|
||||
cleaned = str(value or "").strip()
|
||||
if cleaned and cleaned not in aliases:
|
||||
aliases.append(cleaned)
|
||||
return aliases
|
||||
|
||||
|
||||
def generate_thumbnail(image_path: str, thumb_path: str, size=(400, 400)):
|
||||
"""生成缩略图 - 修复透明 PNG 问题"""
|
||||
with Image.open(image_path) as img:
|
||||
@@ -177,7 +185,7 @@ async def upload_work(
|
||||
|
||||
# 解析标签 - 修复 Bug #3: tags 转字符串
|
||||
if tags:
|
||||
tags_list = [tag.strip() for tag in tags.split(",")]
|
||||
tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
|
||||
tags_str = ",".join(tags_list) # 转成逗号分隔的字符串
|
||||
else:
|
||||
tags_str = None
|
||||
@@ -227,20 +235,22 @@ async def upload_work(
|
||||
|
||||
@router.get("/my", summary="我的上传")
|
||||
def get_my_uploads(
|
||||
page: int = Form(1, ge=1),
|
||||
page_size: int = Form(20, ge=1, le=100),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取当前用户的上传记录"""
|
||||
from sqlalchemy import desc
|
||||
|
||||
aliases = _user_designer_aliases(current_user)
|
||||
offset = (page - 1) * page_size
|
||||
works = db.query(Work).filter(
|
||||
Work.designer == current_user.phone
|
||||
).order_by(desc(Work.created_at)).offset(offset).limit(page_size).all()
|
||||
query = db.query(Work)
|
||||
if aliases:
|
||||
query = query.filter(or_(*[Work.designer == alias for alias in aliases]))
|
||||
else:
|
||||
query = query.filter(Work.id == -1)
|
||||
|
||||
total = db.query(Work).filter(Work.designer == current_user.phone).count()
|
||||
works = query.order_by(desc(Work.created_at)).offset(offset).limit(page_size).all()
|
||||
total = query.count()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
|
||||
@@ -4,11 +4,13 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from typing import List
|
||||
import os
|
||||
import mimetypes
|
||||
from app.core.database import get_db
|
||||
from app.models.work import Work
|
||||
from app.models.order import Order, OrderStatus
|
||||
from app.models.user import User
|
||||
from app.core.security import decode_access_token
|
||||
from app.core.config import settings
|
||||
from app.schemas.work import WorkResponse, WorkListResponse
|
||||
|
||||
router = APIRouter(prefix="/works", tags=["作品"])
|
||||
@@ -137,9 +139,10 @@ def download_work(
|
||||
)
|
||||
|
||||
# 构建原图文件路径
|
||||
# 假设原图存储在 uploads/original/ 目录下
|
||||
file_path = work.original_image.lstrip('/')
|
||||
full_path = os.path.join(os.getcwd(), file_path)
|
||||
relative_path = work.original_image.lstrip("/")
|
||||
if relative_path.startswith("uploads/"):
|
||||
relative_path = relative_path[len("uploads/"):]
|
||||
full_path = os.path.join(settings.UPLOAD_DIR, relative_path)
|
||||
|
||||
# 检查文件是否存在
|
||||
if not os.path.exists(full_path):
|
||||
@@ -150,8 +153,9 @@ def download_work(
|
||||
|
||||
# 返回文件
|
||||
filename = os.path.basename(full_path)
|
||||
media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
return FileResponse(
|
||||
path=full_path,
|
||||
filename=filename,
|
||||
media_type='application/octet-stream'
|
||||
media_type=media_type
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import List
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# 项目信息
|
||||
PROJECT_NAME: str = "爱设计 API"
|
||||
PROJECT_NAME: str = "图绘 API"
|
||||
VERSION: str = "1.0.0"
|
||||
API_PREFIX: str = "/api"
|
||||
|
||||
@@ -12,7 +12,7 @@ class Settings(BaseSettings):
|
||||
# DATABASE_URL: str = "mysql+pymysql://root:password@localhost:3306/aishej" # MySQL配置
|
||||
|
||||
# JWT 配置
|
||||
SECRET_KEY: str = "your-secret-key-change-this"
|
||||
SECRET_KEY: str = "change-me-in-env"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # 24小时
|
||||
|
||||
@@ -21,7 +21,7 @@ class Settings(BaseSettings):
|
||||
MAX_FILE_SIZE: int = 52428800 # 50MB
|
||||
|
||||
# 水印配置
|
||||
WATERMARK_TEXT: str = "爱设计 AiSheji.com"
|
||||
WATERMARK_TEXT: str = "图绘 Tuhui.cloud"
|
||||
WATERMARK_OPACITY: int = 128
|
||||
|
||||
# 缩略图配置
|
||||
@@ -30,19 +30,18 @@ class Settings(BaseSettings):
|
||||
|
||||
# CORS 配置(生产环境配置为具体域名)
|
||||
ALLOWED_ORIGINS: List[str] = [
|
||||
"https://backend.huijie168.uk",
|
||||
"https://app.huijie168.uk",
|
||||
"https://run.huijie168.uk",
|
||||
"https://huijie168.uk",
|
||||
"https://www.huijie168.uk"
|
||||
"https://tuhui.cloud",
|
||||
"https://www.tuhui.cloud",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
]
|
||||
|
||||
# 易收米支付配置
|
||||
YSM_APPID: str = "YSMcd16b45d"
|
||||
YSM_APPSECRET: str = "899850e778e8d2b53e4c4a4e88695688"
|
||||
YSM_NOTIFY_URL: str = "https://backend.huijie168.uk/api/payment/notify" # 支付回调地址
|
||||
YSM_CALLBACK_URL: str = "https://run.huijie168.uk/payment/success" # 支付成功跳转地址
|
||||
YSM_NOPAY_URL: str = "https://run.huijie168.uk/payment/cancel" # 未支付跳转地址
|
||||
YSM_APPID: str = ""
|
||||
YSM_APPSECRET: str = ""
|
||||
YSM_NOTIFY_URL: str = "https://tuhui.cloud/api/payment/notify" # 支付回调地址
|
||||
YSM_CALLBACK_URL: str = "https://tuhui.cloud/payment/success" # 支付成功跳转地址
|
||||
YSM_NOPAY_URL: str = "https://tuhui.cloud/payment/cancel" # 未支付跳转地址
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: tuhui_backend
|
||||
restart: always
|
||||
ports:
|
||||
- "8002:8002"
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
volumes:
|
||||
- ./backend/app:/app/app
|
||||
- /var/www/tuhui_uploads:/app/uploads
|
||||
|
||||
@@ -2,6 +2,24 @@ import axios from 'axios';
|
||||
import { message } from 'antd';
|
||||
import { API_CONFIG } from '../utils/config';
|
||||
|
||||
const parseFilenameFromDisposition = (contentDisposition) => {
|
||||
if (!contentDisposition) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (utf8Match?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(utf8Match[1]);
|
||||
} catch {
|
||||
return utf8Match[1];
|
||||
}
|
||||
}
|
||||
|
||||
const simpleMatch = contentDisposition.match(/filename="?([^"]+)"?/i);
|
||||
return simpleMatch?.[1] || '';
|
||||
};
|
||||
|
||||
/**
|
||||
* 下载作品原图
|
||||
* @param {number} workId - 作品ID
|
||||
@@ -34,7 +52,8 @@ export const downloadWork = async (workId, fileName = null) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName || `work_${workId}.jpg`;
|
||||
const dispositionName = parseFilenameFromDisposition(response.headers?.['content-disposition']);
|
||||
link.download = dispositionName || fileName || `work_${workId}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
@@ -13,6 +13,44 @@ import Header from '../components/Header';
|
||||
import Footer from '../components/Footer';
|
||||
import './WorkDetail.css';
|
||||
|
||||
const parseTags = (rawTags) => {
|
||||
if (!rawTags) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(rawTags)) {
|
||||
return rawTags.filter(Boolean);
|
||||
}
|
||||
if (typeof rawTags !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const trimmed = rawTags.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
|
||||
} catch {
|
||||
// 回退到逗号分隔解析,避免详情页白屏
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const getDownloadFilename = (work) => {
|
||||
const imagePath = String(work?.original_image || '');
|
||||
const extMatch = imagePath.match(/\.[a-zA-Z0-9]+$/);
|
||||
const ext = extMatch ? extMatch[0] : '.jpg';
|
||||
return `${work?.title || 'work'}${ext}`;
|
||||
};
|
||||
|
||||
const WorkDetail = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -97,7 +135,7 @@ const WorkDetail = () => {
|
||||
}
|
||||
|
||||
// 尝试直接下载
|
||||
const result = await downloadWork(id, `${workData.title}.jpg`);
|
||||
const result = await downloadWork(id, getDownloadFilename(workData));
|
||||
|
||||
// 如果需要购买,显示购买确认弹窗
|
||||
if (!result.success && result.needPurchase) {
|
||||
@@ -148,7 +186,7 @@ const WorkDetail = () => {
|
||||
setPaymentUrl('');
|
||||
message.success('支付成功!开始下载...');
|
||||
// 重新尝试下载
|
||||
await downloadWork(id, `${workData.title}.jpg`);
|
||||
await downloadWork(id, getDownloadFilename(workData));
|
||||
}
|
||||
}
|
||||
}, 3000); // 每3秒检查一次
|
||||
@@ -212,7 +250,7 @@ const WorkDetail = () => {
|
||||
}
|
||||
|
||||
// 解析标签
|
||||
const tags = workData.tags ? (typeof workData.tags === 'string' ? JSON.parse(workData.tags) : workData.tags) : [];
|
||||
const tags = parseTags(workData.tags);
|
||||
|
||||
return (
|
||||
<div className="work-detail-page">
|
||||
|
||||
Reference in New Issue
Block a user