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=["订单"])
|
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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user