From c23c4ac1e32555fa737d0afb553e9be99aa712ec Mon Sep 17 00:00:00 2001 From: jimi <1847930177@qq.com> Date: Sun, 8 Mar 2026 19:40:22 +0800 Subject: [PATCH] fix: harden uploads downloads and deployment config --- backend/app/api/orders.py | 28 +++++++++++++---- backend/app/api/upload.py | 36 ++++++++++++++-------- backend/app/api/works.py | 12 +++++--- backend/app/core/config.py | 25 ++++++++-------- docker-compose.yml | 7 +++-- frontend/src/api/download.js | 21 ++++++++++++- frontend/src/pages/WorkDetail.jsx | 50 +++++++++++++++++++++++++++---- 7 files changed, 133 insertions(+), 46 deletions(-) diff --git a/backend/app/api/orders.py b/backend/app/api/orders.py index 4f5048f..41979bf 100644 --- a/backend/app/api/orders.py +++ b/backend/app/api/orders.py @@ -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( diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index 5c14c6f..144a215 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -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() - - total = db.query(Work).filter(Work.designer == current_user.phone).count() + query = db.query(Work) + if aliases: + query = query.filter(or_(*[Work.designer == alias for alias in aliases])) + else: + query = query.filter(Work.id == -1) + + works = query.order_by(desc(Work.created_at)).offset(offset).limit(page_size).all() + total = query.count() return { "total": total, diff --git a/backend/app/api/works.py b/backend/app/api/works.py index 076fc96..52added 100644 --- a/backend/app/api/works.py +++ b/backend/app/api/works.py @@ -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 ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 26069be..1fe0c65 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index 2e19869..1a58c3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/api/download.js b/frontend/src/api/download.js index 26337e5..4238871 100644 --- a/frontend/src/api/download.js +++ b/frontend/src/api/download.js @@ -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); diff --git a/frontend/src/pages/WorkDetail.jsx b/frontend/src/pages/WorkDetail.jsx index be15b14..8634c66 100644 --- a/frontend/src/pages/WorkDetail.jsx +++ b/frontend/src/pages/WorkDetail.jsx @@ -9,9 +9,47 @@ import { createPayment, queryPaymentStatus } from '../api/payment'; import { downloadWork } from '../api/download'; import { isLoggedIn } from '../api/auth'; import { API_CONFIG } from '../utils/config'; -import Header from '../components/Header'; -import Footer from '../components/Footer'; -import './WorkDetail.css'; +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(); @@ -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 (