fix: harden uploads downloads and deployment config

This commit is contained in:
2026-03-08 19:40:22 +08:00
parent aa2e6bbe95
commit c23c4ac1e3
7 changed files with 133 additions and 46 deletions

View File

@@ -12,6 +12,26 @@ from app.schemas.order import OrderCreate, OrderResponse, PaymentResponse
router = APIRouter(prefix="/orders", tags=["订单"]) 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)): def get_current_user(authorization: str = Header(None), db: Session = Depends(get_db)):
"""获取当前登录用户""" """获取当前登录用户"""
if not authorization or not authorization.startswith("Bearer "): if not authorization or not authorization.startswith("Bearer "):
@@ -66,12 +86,8 @@ def create_order(
detail="您已购买过此作品" detail="您已购买过此作品"
) )
# 生成订单号:前缀 + (当前时间-6个月)的年月日 + 当前时间的时分秒 # 生成唯一订单号:半年前日期 + 当前时分秒 + 随机后缀
now = datetime.now() order_no = _generate_order_no(db)
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}"
# 创建订单 # 创建订单
new_order = Order( new_order = Order(

View File

@@ -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.orm import Session
from sqlalchemy import desc, or_
from typing import Optional from typing import Optional
import os import os
import uuid import uuid
import shutil import shutil
from datetime import datetime from datetime import datetime
from PIL import Image from PIL import Image
import json
from app.core.database import get_db from app.core.database import get_db
from app.models.work import Work from app.models.work import Work
from app.models.user import User from app.models.user import User
@@ -51,6 +50,15 @@ def get_current_user(authorization: str = Header(None), db: Session = Depends(ge
return user 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)): def generate_thumbnail(image_path: str, thumb_path: str, size=(400, 400)):
"""生成缩略图 - 修复透明 PNG 问题""" """生成缩略图 - 修复透明 PNG 问题"""
with Image.open(image_path) as img: with Image.open(image_path) as img:
@@ -177,7 +185,7 @@ async def upload_work(
# 解析标签 - 修复 Bug #3: tags 转字符串 # 解析标签 - 修复 Bug #3: tags 转字符串
if 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) # 转成逗号分隔的字符串 tags_str = ",".join(tags_list) # 转成逗号分隔的字符串
else: else:
tags_str = None tags_str = None
@@ -227,20 +235,22 @@ async def upload_work(
@router.get("/my", summary="我的上传") @router.get("/my", summary="我的上传")
def get_my_uploads( def get_my_uploads(
page: int = Form(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Form(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""获取当前用户的上传记录""" """获取当前用户的上传记录"""
from sqlalchemy import desc aliases = _user_designer_aliases(current_user)
offset = (page - 1) * page_size offset = (page - 1) * page_size
works = db.query(Work).filter( query = db.query(Work)
Work.designer == current_user.phone if aliases:
).order_by(desc(Work.created_at)).offset(offset).limit(page_size).all() query = query.filter(or_(*[Work.designer == alias for alias in aliases]))
else:
total = db.query(Work).filter(Work.designer == current_user.phone).count() query = query.filter(Work.id == -1)
works = query.order_by(desc(Work.created_at)).offset(offset).limit(page_size).all()
total = query.count()
return { return {
"total": total, "total": total,

View File

@@ -4,11 +4,13 @@ from sqlalchemy.orm import Session
from sqlalchemy import desc from sqlalchemy import desc
from typing import List from typing import List
import os import os
import mimetypes
from app.core.database import get_db from app.core.database import get_db
from app.models.work import Work from app.models.work import Work
from app.models.order import Order, OrderStatus from app.models.order import Order, OrderStatus
from app.models.user import User from app.models.user import User
from app.core.security import decode_access_token from app.core.security import decode_access_token
from app.core.config import settings
from app.schemas.work import WorkResponse, WorkListResponse from app.schemas.work import WorkResponse, WorkListResponse
router = APIRouter(prefix="/works", tags=["作品"]) router = APIRouter(prefix="/works", tags=["作品"])
@@ -137,9 +139,10 @@ def download_work(
) )
# 构建原图文件路径 # 构建原图文件路径
# 假设原图存储在 uploads/original/ 目录下 relative_path = work.original_image.lstrip("/")
file_path = work.original_image.lstrip('/') if relative_path.startswith("uploads/"):
full_path = os.path.join(os.getcwd(), file_path) relative_path = relative_path[len("uploads/"):]
full_path = os.path.join(settings.UPLOAD_DIR, relative_path)
# 检查文件是否存在 # 检查文件是否存在
if not os.path.exists(full_path): if not os.path.exists(full_path):
@@ -150,8 +153,9 @@ def download_work(
# 返回文件 # 返回文件
filename = os.path.basename(full_path) filename = os.path.basename(full_path)
media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
return FileResponse( return FileResponse(
path=full_path, path=full_path,
filename=filename, filename=filename,
media_type='application/octet-stream' media_type=media_type
) )

View File

@@ -3,7 +3,7 @@ from typing import List
class Settings(BaseSettings): class Settings(BaseSettings):
# 项目信息 # 项目信息
PROJECT_NAME: str = "爱设计 API" PROJECT_NAME: str = "图绘 API"
VERSION: str = "1.0.0" VERSION: str = "1.0.0"
API_PREFIX: str = "/api" API_PREFIX: str = "/api"
@@ -12,7 +12,7 @@ class Settings(BaseSettings):
# DATABASE_URL: str = "mysql+pymysql://root:password@localhost:3306/aishej" # MySQL配置 # DATABASE_URL: str = "mysql+pymysql://root:password@localhost:3306/aishej" # MySQL配置
# JWT 配置 # JWT 配置
SECRET_KEY: str = "your-secret-key-change-this" SECRET_KEY: str = "change-me-in-env"
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # 24小时 ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # 24小时
@@ -21,7 +21,7 @@ class Settings(BaseSettings):
MAX_FILE_SIZE: int = 52428800 # 50MB MAX_FILE_SIZE: int = 52428800 # 50MB
# 水印配置 # 水印配置
WATERMARK_TEXT: str = "爱设计 AiSheji.com" WATERMARK_TEXT: str = "图绘 Tuhui.cloud"
WATERMARK_OPACITY: int = 128 WATERMARK_OPACITY: int = 128
# 缩略图配置 # 缩略图配置
@@ -30,19 +30,18 @@ class Settings(BaseSettings):
# CORS 配置(生产环境配置为具体域名) # CORS 配置(生产环境配置为具体域名)
ALLOWED_ORIGINS: List[str] = [ ALLOWED_ORIGINS: List[str] = [
"https://backend.huijie168.uk", "https://tuhui.cloud",
"https://app.huijie168.uk", "https://www.tuhui.cloud",
"https://run.huijie168.uk", "http://localhost:5173",
"https://huijie168.uk", "http://127.0.0.1:5173",
"https://www.huijie168.uk"
] ]
# 易收米支付配置 # 易收米支付配置
YSM_APPID: str = "YSMcd16b45d" YSM_APPID: str = ""
YSM_APPSECRET: str = "899850e778e8d2b53e4c4a4e88695688" YSM_APPSECRET: str = ""
YSM_NOTIFY_URL: str = "https://backend.huijie168.uk/api/payment/notify" # 支付回调地址 YSM_NOTIFY_URL: str = "https://tuhui.cloud/api/payment/notify" # 支付回调地址
YSM_CALLBACK_URL: str = "https://run.huijie168.uk/payment/success" # 支付成功跳转地址 YSM_CALLBACK_URL: str = "https://tuhui.cloud/payment/success" # 支付成功跳转地址
YSM_NOPAY_URL: str = "https://run.huijie168.uk/payment/cancel" # 未支付跳转地址 YSM_NOPAY_URL: str = "https://tuhui.cloud/payment/cancel" # 未支付跳转地址
class Config: class Config:
env_file = ".env" env_file = ".env"

View File

@@ -1,12 +1,13 @@
version: '3.8'
services: services:
api: api:
build: . build:
context: ./backend
container_name: tuhui_backend container_name: tuhui_backend
restart: always restart: always
ports: ports:
- "8002:8002" - "8002:8002"
env_file:
- ./backend/.env
volumes: volumes:
- ./backend/app:/app/app - ./backend/app:/app/app
- /var/www/tuhui_uploads:/app/uploads - /var/www/tuhui_uploads:/app/uploads

View File

@@ -2,6 +2,24 @@ import axios from 'axios';
import { message } from 'antd'; import { message } from 'antd';
import { API_CONFIG } from '../utils/config'; 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 * @param {number} workId - 作品ID
@@ -34,7 +52,8 @@ export const downloadWork = async (workId, fileName = null) => {
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; 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); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);

View File

@@ -9,9 +9,47 @@ import { createPayment, queryPaymentStatus } from '../api/payment';
import { downloadWork } from '../api/download'; import { downloadWork } from '../api/download';
import { isLoggedIn } from '../api/auth'; import { isLoggedIn } from '../api/auth';
import { API_CONFIG } from '../utils/config'; import { API_CONFIG } from '../utils/config';
import Header from '../components/Header'; import Header from '../components/Header';
import Footer from '../components/Footer'; import Footer from '../components/Footer';
import './WorkDetail.css'; 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 WorkDetail = () => {
const { id } = useParams(); const { id } = useParams();
@@ -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) { if (!result.success && result.needPurchase) {
@@ -148,7 +186,7 @@ const WorkDetail = () => {
setPaymentUrl(''); setPaymentUrl('');
message.success('支付成功!开始下载...'); message.success('支付成功!开始下载...');
// 重新尝试下载 // 重新尝试下载
await downloadWork(id, `${workData.title}.jpg`); await downloadWork(id, getDownloadFilename(workData));
} }
} }
}, 3000); // 每3秒检查一次 }, 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 ( return (
<div className="work-detail-page"> <div className="work-detail-page">