chore: initialize tuhui repository

This commit is contained in:
Codex
2026-03-08 19:28:32 +08:00
commit ee10c46aae
189 changed files with 17754 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""API 路由"""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

79
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,79 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import get_password_hash, verify_password, create_access_token
from app.models.user import User
from app.schemas.user import UserRegister, UserLogin, Token, UserResponse
router = APIRouter(prefix="/auth", tags=["认证"])
@router.post("/register", response_model=Token, summary="用户注册")
def register(user_data: UserRegister, db: Session = Depends(get_db)):
"""用户注册(使用手机号,无需验证码)"""
try:
# 检查手机号是否已存在
existing_user = db.query(User).filter(User.phone == user_data.phone).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该手机号已被注册"
)
# 创建新用户
hashed_password = get_password_hash(user_data.password)
new_user = User(
phone=user_data.phone,
password_hash=hashed_password,
nickname=user_data.nickname or f"用户{user_data.phone[-4:]}",
balance=0.0
)
db.add(new_user)
db.commit()
db.refresh(new_user)
# 生成 Token
access_token = create_access_token(data={"sub": str(new_user.id)})
return Token(
access_token=access_token,
user=UserResponse.from_orm(new_user)
)
except HTTPException:
raise
except Exception as e:
print(f"[ERROR] 注册失败: {e}")
print(f"[ERROR] 错误类型: {type(e).__name__}")
import traceback
traceback.print_exc()
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"注册失败: {str(e)}"
)
@router.post("/login", response_model=Token, summary="用户登录")
def login(credentials: UserLogin, db: Session = Depends(get_db)):
"""用户登录(使用手机号)"""
# 查找用户
user = db.query(User).filter(User.phone == credentials.phone).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="手机号或密码错误"
)
# 验证密码
if not verify_password(credentials.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="手机号或密码错误"
)
# 生成 Token
access_token = create_access_token(data={"sub": str(user.id)})
return Token(
access_token=access_token,
user=UserResponse.from_orm(user)
)

171
backend/app/api/orders.py Normal file
View File

@@ -0,0 +1,171 @@
from fastapi import APIRouter, Depends, HTTPException, status, Header
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
import secrets
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models.user import User
from app.models.work import Work
from app.models.order import Order, OrderStatus
from app.models.download import DownloadRecord
from app.schemas.order import OrderCreate, OrderResponse, PaymentResponse
router = APIRouter(prefix="/orders", tags=["订单"])
def get_current_user(authorization: str = Header(None), db: Session = Depends(get_db)):
"""获取当前登录用户"""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未登录"
)
token = authorization.replace("Bearer ", "")
payload = decode_access_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 无效或已过期"
)
user_id = int(payload.get("sub"))
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在"
)
return user
@router.post("/create", response_model=OrderResponse, summary="创建订单")
def create_order(
order_data: OrderCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""创建下载订单"""
# 查找作品
work = db.query(Work).filter(Work.id == order_data.work_id).first()
if not work:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="作品不存在"
)
# 检查是否已购买
existing_order = db.query(Order).filter(
Order.user_id == current_user.id,
Order.work_id == work.id,
Order.status == OrderStatus.PAID
).first()
if existing_order:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
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}"
# 创建订单
new_order = Order(
order_no=order_no,
user_id=current_user.id,
work_id=work.id,
amount=work.price,
payment_method=order_data.payment_method,
status=OrderStatus.PENDING
)
db.add(new_order)
db.commit()
db.refresh(new_order)
return new_order
@router.post("/pay/{order_id}", response_model=PaymentResponse, summary="支付订单")
def pay_order(
order_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
支付订单(模拟支付)
实际生产环境需要对接真实支付接口
"""
# 查找订单
order = db.query(Order).filter(
Order.id == order_id,
Order.user_id == current_user.id
).first()
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="订单不存在"
)
if order.status != OrderStatus.PENDING:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="订单状态异常"
)
# 余额支付
if order.payment_method == "balance":
if current_user.balance < order.amount:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="余额不足"
)
# 扣除余额
current_user.balance -= order.amount
# 更新订单状态
order.status = OrderStatus.PAID
order.paid_at = datetime.now()
# 创建下载记录
download_record = DownloadRecord(
user_id=current_user.id,
work_id=order.work_id,
order_id=order.id
)
db.add(download_record)
# 增加作品下载量
work = db.query(Work).filter(Work.id == order.work_id).first()
work.downloads += 1
db.commit()
return PaymentResponse(
success=True,
message="支付成功",
order_no=order.order_no,
download_url=f"/api/works/{work.id}/download"
)
# 其他支付方式(支付宝、微信)
else:
return PaymentResponse(
success=False,
message="暂不支持该支付方式,请使用余额支付或联系管理员",
order_no=order.order_no
)
@router.get("/my", summary="我的订单")
def get_my_orders(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取当前用户的订单列表"""
orders = db.query(Order).filter(Order.user_id == current_user.id).all()
return orders

267
backend/app/api/payment.py Normal file
View File

@@ -0,0 +1,267 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""支付相关接口"""
from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks
from sqlalchemy.orm import Session
from sqlalchemy import and_
from datetime import datetime
import json
from app.core.database import get_db
from app.core.config import settings
from app.models.order import Order, OrderStatus
from app.models.work import Work
from app.models.download import DownloadRecord
from app.ysm_sdk import create_payment, query_order, PaymentNotify
from app.api.orders import get_current_user
from app.models.user import User
router = APIRouter(prefix="/payment", tags=["支付"])
@router.post("/create/{order_id}")
async def create_payment_url(
order_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
创建支付链接(真实支付)
Args:
order_id: 订单ID
Returns:
{
"pay_url": "支付链接",
"order_number": "订单号",
"amount": 金额(分)
}
"""
# 查询订单
order = db.query(Order).filter(
and_(
Order.id == order_id,
Order.user_id == current_user.id
)
).first()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
# 检查订单状态
if order.status == OrderStatus.PAID:
raise HTTPException(status_code=400, detail="订单已支付")
if order.status == OrderStatus.CANCELLED:
raise HTTPException(status_code=400, detail="订单已取消")
# 查询作品信息
work = db.query(Work).filter(Work.id == order.work_id).first()
if not work:
raise HTTPException(status_code=404, detail="作品不存在")
try:
# 创建支付
pay_url = await create_payment(
appid=settings.YSM_APPID,
appsecret=settings.YSM_APPSECRET,
order_id=order.order_no, # 使用 order_no 字段
description=f"{work.title} - 爱设计作品购买",
amount=int(order.amount * 100), # 元转分
notify_url=settings.YSM_NOTIFY_URL,
nopay_url=settings.YSM_NOPAY_URL,
callback_url=settings.YSM_CALLBACK_URL,
pay_type=1 # 默认微信内支付
)
return {
"pay_url": pay_url,
"order_number": order.order_no, # 使用 order_no 字段
"amount": int(order.amount * 100),
"description": f"{work.title} - 爱设计作品购买"
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/notify")
async def payment_notify(
request: Request,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db)
):
"""
支付回调接口(易收米异步通知)
当用户支付成功后,支付平台会异步请求此接口
"""
# 创建通知处理器
notify_handler = PaymentNotify(
appid=settings.YSM_APPID,
appsecret=settings.YSM_APPSECRET
)
# 获取请求数据
try:
request_data = await request.body()
data = json.loads(request_data.decode('utf-8'))
except Exception as e:
return {"error": "数据格式错误"}
# 验证签名
success, message = await notify_handler.process(data)
if not success:
return {"error": message}
# 获取商户订单号
mch_orderid = data.get('mch_orderid')
# 查询订单
order = db.query(Order).filter(Order.order_no == mch_orderid).first()
if not order:
return {"error": "订单不存在"}
# 防止重复处理
if order.status == OrderStatus.PAID:
return "success"
# 更新订单状态
if data.get('state') == 'SUCCESS':
try:
# 更新订单
order.status = OrderStatus.PAID
order.payment_method = "ysm_wechat" # 易收米微信支付
order.paid_at = datetime.now()
# 创建下载记录
download_record = DownloadRecord(
user_id=order.user_id,
work_id=order.work_id,
order_id=order.id
)
db.add(download_record)
# 更新作品下载次数
work = db.query(Work).filter(Work.id == order.work_id).first()
if work:
work.downloads += 1
db.commit()
return "success"
except Exception as e:
db.rollback()
print(f"处理支付回调失败: {str(e)}")
return {"error": "处理失败"}
return {"error": "支付未完成"}
@router.get("/query/{order_number}")
async def query_order_status(
order_number: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
查询订单支付状态
Args:
order_number: 订单号
Returns:
订单状态信息
"""
# 查询本地订单
order = db.query(Order).filter(
and_(
Order.order_no == order_number,
Order.user_id == current_user.id
)
).first()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
try:
# 查询易收米订单状态
result = await query_order(
appid=settings.YSM_APPID,
mch_orderid=order_number
)
if result:
# 如果远程订单已支付,但本地订单未更新,则更新本地订单
if result.get('state') == 'SUCCESS' and order.status != OrderStatus.PAID:
order.status = OrderStatus.PAID
order.payment_method = "ysm_wechat"
order.paid_at = datetime.now()
# 创建下载记录
download_record = DownloadRecord(
user_id=order.user_id,
work_id=order.work_id,
order_id=order.id
)
db.add(download_record)
# 更新作品下载次数
work = db.query(Work).filter(Work.id == order.work_id).first()
if work:
work.downloads += 1
db.commit()
return {
"order_number": order_number,
"local_status": order.status,
"remote_status": result.get('state'),
"amount": order.amount,
"paid_at": order.paid_at.isoformat() if order.paid_at else None,
"remote_info": result
}
else:
raise HTTPException(status_code=500, detail="查询订单失败")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/cancel/{order_id}")
async def cancel_order(
order_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
取消订单
Args:
order_id: 订单ID
"""
# 查询订单
order = db.query(Order).filter(
and_(
Order.id == order_id,
Order.user_id == current_user.id
)
).first()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
# 只能取消待支付的订单
if order.status != OrderStatus.PENDING:
raise HTTPException(status_code=400, detail="订单状态不允许取消")
order.status = OrderStatus.CANCELLED
db.commit()
return {"message": "订单已取消"}

250
backend/app/api/upload.py Normal file
View File

@@ -0,0 +1,250 @@
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Header
from sqlalchemy.orm import Session
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
from app.core.security import decode_access_token
router = APIRouter(prefix="/upload", tags=["上传"])
# 上传配置
UPLOAD_BASE_DIR = "/app/uploads"
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
# 图绘域名
TUHUI_DOMAIN = "https://tuhui.cloud"
def get_current_user(authorization: str = Header(None), db: Session = Depends(get_db)):
"""获取当前登录用户"""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未登录,请先登录"
)
token = authorization.replace("Bearer ", "")
payload = decode_access_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 无效或已过期"
)
user_id = int(payload.get("sub"))
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在"
)
return user
def generate_thumbnail(image_path: str, thumb_path: str, size=(400, 400)):
"""生成缩略图 - 修复透明 PNG 问题"""
with Image.open(image_path) as img:
# 修复 Bug #1: 透明 PNG 转 RGB 后再保存为 JPEG
if img.mode in ('RGBA', 'LA', 'P'):
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
if img.mode in ('RGBA', 'LA'):
background.paste(img, mask=img.split()[-1])
img = background
elif img.mode != 'RGB':
img = img.convert('RGB')
img.thumbnail(size, Image.Resampling.LANCZOS)
img.save(thumb_path, quality=85)
def add_watermark(image_path: str, watermarked_path: str, watermark_text: str = "图绘"):
"""添加水印 - 修复透明 PNG 问题"""
with Image.open(image_path) as img:
# 修复 Bug #1: 透明 PNG 转 RGB 后再保存为 JPEG
if img.mode in ('RGBA', 'LA', 'P'):
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
if img.mode in ('RGBA', 'LA'):
background.paste(img, mask=img.split()[-1])
img = background
elif img.mode != 'RGB':
img = img.convert('RGB')
width, height = img.size
from PIL import ImageDraw, ImageFont
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 36)
except:
font = ImageFont.load_default()
text = watermark_text
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = width - text_width - 20
y = height - text_height - 20
draw.text((x, y), text, fill=(255, 255, 255, 128), font=font)
img.save(watermarked_path, quality=90)
@router.post("", summary="上传作品")
async def upload_work(
file: UploadFile = File(..., description="作品图片文件"),
title: str = Form(..., description="作品标题"),
description: Optional[str] = Form(None, description="作品描述"),
category: str = Form(..., description="作品分类"),
tags: Optional[str] = Form(None, description="标签,逗号分隔"),
price: float = Form(..., ge=0, description="作品价格"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
📤 上传作品 API
**需要登录**Bearer Token
**支持格式**jpg, jpeg, png, gif, webp
**文件大小**:最大 50MB
"""
# 验证文件扩展名
file_ext = os.path.splitext(file.filename)[1].lower()
if file_ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"不支持的文件格式,仅支持:{', '.join(ALLOWED_EXTENSIONS)}"
)
# 验证文件大小
file.file.seek(0, 2)
file_size = file.file.tell()
file.file.seek(0)
if file_size > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"文件过大,最大支持 {MAX_FILE_SIZE // 1024 // 1024}MB"
)
# 生成唯一文件名
unique_id = str(uuid.uuid4())
timestamp = datetime.now().strftime("%Y%m%d")
# 创建上传目录
upload_dir = os.path.join(UPLOAD_BASE_DIR, "original", timestamp)
thumb_dir = os.path.join(UPLOAD_BASE_DIR, "thumbnail", timestamp)
watermarked_dir = os.path.join(UPLOAD_BASE_DIR, "watermarked", timestamp)
os.makedirs(upload_dir, exist_ok=True)
os.makedirs(thumb_dir, exist_ok=True)
os.makedirs(watermarked_dir, exist_ok=True)
# 保存文件
original_filename = f"{unique_id}{file_ext}"
original_path = os.path.join(upload_dir, original_filename)
thumb_filename = f"{unique_id}_thumb.jpg"
thumb_path = os.path.join(thumb_dir, thumb_filename)
watermarked_filename = f"{unique_id}_watermarked.jpg"
watermarked_path = os.path.join(watermarked_dir, watermarked_filename)
try:
# 保存原图
with open(original_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# 生成缩略图
generate_thumbnail(original_path, thumb_path)
# 生成水印图
add_watermark(original_path, watermarked_path)
# 解析标签 - 修复 Bug #3: tags 转字符串
if tags:
tags_list = [tag.strip() for tag in tags.split(",")]
tags_str = ",".join(tags_list) # 转成逗号分隔的字符串
else:
tags_str = None
# 创建数据库记录 - 修复所有字段问题
work = Work(
title=title,
description=description or "",
category=category,
tags=tags_str, # 修复:使用字符串而不是列表
price=price,
designer=current_user.nickname or current_user.phone,
original_image=f"/uploads/original/{timestamp}/{original_filename}",
thumbnail_image=f"/uploads/thumbnail/{timestamp}/{thumb_filename}",
watermarked_image=f"/uploads/watermarked/{timestamp}/{watermarked_filename}"
)
db.add(work)
db.commit()
db.refresh(work)
# 构建完整的图片 URL
image_url = f"{TUHUI_DOMAIN}{work.original_image}"
thumbnail_url = f"{TUHUI_DOMAIN}{work.thumbnail_image}"
watermarked_url = f"{TUHUI_DOMAIN}{work.watermarked_image}"
return {
"success": True,
"message": "上传成功,等待审核",
"work_id": work.id,
"image_url": image_url,
"thumbnail_url": thumbnail_url,
"watermarked_url": watermarked_url
}
except Exception as e:
# 清理已上传的文件
for path in [original_path, thumb_path, watermarked_path]:
if os.path.exists(path):
os.remove(path)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"上传失败:{str(e)}"
)
@router.get("/my", summary="我的上传")
def get_my_uploads(
page: int = Form(1, ge=1),
page_size: int = Form(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取当前用户的上传记录"""
from sqlalchemy import desc
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()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": works
}

View File

@@ -0,0 +1,256 @@
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Header
from sqlalchemy.orm import Session
from typing import Optional
import os
import uuid
import shutil
from datetime import datetime
from PIL import Image
from app.core.database import get_db
from app.models.work import Work
from app.models.user import User
from app.core.security import decode_access_token
router = APIRouter(prefix="/upload", tags=["上传"])
# 上传配置
UPLOAD_BASE_DIR = "/var/www/tuhui_uploads"
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
# 图绘域名
TUHUI_DOMAIN = "https://tuhui.cloud"
def get_current_user(authorization: str = Header(None), db: Session = Depends(get_db)):
"""获取当前登录用户"""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未登录,请先登录"
)
token = authorization.replace("Bearer ", "")
payload = decode_access_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 无效或已过期"
)
user_id = int(payload.get("sub"))
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在"
)
return user
def generate_thumbnail(image_path: str, thumb_path: str, size=(400, 400)):
"""生成缩略图"""
with Image.open(image_path) as img:
img.thumbnail(size, Image.Resampling.LANCZOS)
img.save(thumb_path, quality=85)
def add_watermark(image_path: str, watermarked_path: str, watermark_text: str = "图绘"):
"""添加水印"""
with Image.open(image_path) as img:
width, height = img.size
from PIL import ImageDraw, ImageFont
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 36)
except:
font = ImageFont.load_default()
text = watermark_text
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = width - text_width - 20
y = height - text_height - 20
draw.text((x, y), text, fill=(255, 255, 255, 128), font=font)
img.save(watermarked_path, quality=90)
@router.post("", summary="上传作品")
async def upload_work(
file: UploadFile = File(..., description="作品图片文件"),
title: str = Form(..., description="作品标题"),
description: Optional[str] = Form(None, description="作品描述"),
category: str = Form(..., description="作品分类"),
tags: Optional[str] = Form(None, description="标签,逗号分隔"),
price: float = Form(..., ge=0, description="作品价格"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
📤 上传作品 API
**需要登录**Bearer Token
**支持格式**jpg, jpeg, png, gif, webp
**文件大小**:最大 50MB
**处理流程**
1. 验证文件类型和大小
2. 保存原图
3. 自动生成缩略图400x400
4. 自动生成水印图
5. 创建数据库记录
6. 返回 work_id 和 image_url
"""
# 验证文件扩展名
file_ext = os.path.splitext(file.filename)[1].lower()
if file_ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"不支持的文件格式,仅支持:{', '.join(ALLOWED_EXTENSIONS)}"
)
# 验证文件大小
file.file.seek(0, 2)
file_size = file.file.tell()
file.file.seek(0)
if file_size > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"文件过大,最大支持 {MAX_FILE_SIZE // 1024 // 1024}MB"
)
# 生成唯一文件名
unique_id = str(uuid.uuid4())
timestamp = datetime.now().strftime("%Y%m%d")
# 创建上传目录
upload_dir = os.path.join(UPLOAD_BASE_DIR, "original", timestamp)
thumb_dir = os.path.join(UPLOAD_BASE_DIR, "thumbnail", timestamp)
watermarked_dir = os.path.join(UPLOAD_BASE_DIR, "watermarked", timestamp)
os.makedirs(upload_dir, exist_ok=True)
os.makedirs(thumb_dir, exist_ok=True)
os.makedirs(watermarked_dir, exist_ok=True)
# 保存文件
original_filename = f"{unique_id}{file_ext}"
original_path = os.path.join(upload_dir, original_filename)
thumb_filename = f"{unique_id}_thumb.jpg"
thumb_path = os.path.join(thumb_dir, thumb_filename)
watermarked_filename = f"{unique_id}_watermarked.jpg"
watermarked_path = os.path.join(watermarked_dir, watermarked_filename)
try:
# 保存原图
with open(original_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# 生成缩略图
generate_thumbnail(original_path, thumb_path)
# 生成水印图
add_watermark(original_path, watermarked_path)
# 解析标签
tags_list = [tag.strip() for tag in tags.split(",")] if tags else []
# 获取图片尺寸
with Image.open(original_path) as img:
img_width, img_height = img.size
# 创建数据库记录
work = Work(
title=title,
description=description or "",
category=category,
tags=tags_list,
price=price,
designer_id=current_user.id,
original_image=f"/uploads/original/{timestamp}/{original_filename}",
thumbnail_image=f"/uploads/thumbnail/{timestamp}/{thumb_filename}",
watermarked_image=f"/uploads/watermarked/{timestamp}/{watermarked_filename}",
width=img_width,
height=img_height,
file_size=file_size,
status="pending" # pending, approved, rejected
)
db.add(work)
db.commit()
db.refresh(work)
# 构建完整的图片 URL
image_url = f"{TUHUI_DOMAIN}{work.original_image}"
thumbnail_url = f"{TUHUI_DOMAIN}{work.thumbnail_image}"
watermarked_url = f"{TUHUI_DOMAIN}{work.watermarked_image}"
# 返回结果(包含 work_id 和 image_url
return {
"success": True,
"message": "上传成功,等待审核",
"work_id": work.id,
"image_url": image_url,
"thumbnail_url": thumbnail_url,
"watermarked_url": watermarked_url,
"work": {
"id": work.id,
"title": work.title,
"description": work.description,
"category": work.category,
"tags": work.tags,
"price": work.price,
"width": work.width,
"height": work.height,
"file_size": work.file_size,
"status": work.status,
"original_image": work.original_image,
"thumbnail_image": work.thumbnail_image,
"watermarked_image": work.watermarked_image,
"created_at": work.created_at.isoformat() if work.created_at else None
}
}
except Exception as e:
# 清理已上传的文件
for path in [original_path, thumb_path, watermarked_path]:
if os.path.exists(path):
os.remove(path)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"上传失败:{str(e)}"
)
@router.get("/my", summary="我的上传")
def get_my_uploads(
page: int = Form(1, ge=1),
page_size: int = Form(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取当前用户的上传记录"""
from sqlalchemy import desc
offset = (page - 1) * page_size
works = db.query(Work).filter(
Work.designer_id == current_user.id
).order_by(desc(Work.created_at)).offset(offset).limit(page_size).all()
total = db.query(Work).filter(Work.designer_id == current_user.id).count()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": works
}

157
backend/app/api/works.py Normal file
View File

@@ -0,0 +1,157 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query, Header
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from sqlalchemy import desc
from typing import List
import os
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.schemas.work import WorkResponse, WorkListResponse
router = APIRouter(prefix="/works", tags=["作品"])
@router.get("", response_model=WorkListResponse, summary="获取作品列表")
def get_works(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category: str = None,
keyword: str = None,
db: Session = Depends(get_db)
):
"""
获取作品列表
- page: 页码
- page_size: 每页数量
- category: 分类筛选
- keyword: 关键词搜索
"""
query = db.query(Work)
# 分类筛选
if category:
query = query.filter(Work.category == category)
# 关键词搜索
if keyword:
query = query.filter(Work.title.contains(keyword))
# 获取总数
total = query.count()
# 分页查询
offset = (page - 1) * page_size
works = query.order_by(desc(Work.created_at)).offset(offset).limit(page_size).all()
# 修复:将 level_text 为 NULL 的转为空字符串
for work in works:
if work.level_text is None:
work.level_text = ""
return WorkListResponse(
total=total,
items=works
)
@router.get("/{work_id}", response_model=WorkResponse, summary="获取作品详情")
def get_work(work_id: int, db: Session = Depends(get_db)):
"""获取作品详情"""
work = db.query(Work).filter(Work.id == work_id).first()
if not work:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="作品不存在"
)
# 增加浏览量
work.views += 1
db.commit()
# 修复:将 level_text 为 NULL 的转为空字符串
if work.level_text is None:
work.level_text = ""
return work
def get_current_user(authorization: str = Header(None), db: Session = Depends(get_db)):
"""获取当前登录用户(用于下载验证)"""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未登录,请先登录"
)
token = authorization.replace("Bearer ", "")
payload = decode_access_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 无效或已过期"
)
user_id = int(payload.get("sub"))
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在"
)
return user
@router.get("/{work_id}/download", summary="下载作品原图")
def download_work(
work_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
下载作品原图
- 需要登录
- 必须已支付订单
- 支付成功后才能下载
"""
# 查找作品
work = db.query(Work).filter(Work.id == work_id).first()
if not work:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="作品不存在"
)
# 检查是否已购买(查询已支付的订单)
paid_order = db.query(Order).filter(
Order.user_id == current_user.id,
Order.work_id == work_id,
Order.status == OrderStatus.PAID
).first()
if not paid_order:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="您还未购买此作品,请先完成支付"
)
# 构建原图文件路径
# 假设原图存储在 uploads/original/ 目录下
file_path = work.original_image.lstrip('/')
full_path = os.path.join(os.getcwd(), file_path)
# 检查文件是否存在
if not os.path.exists(full_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文件不存在"
)
# 返回文件
filename = os.path.basename(full_path)
return FileResponse(
path=full_path,
filename=filename,
media_type='application/octet-stream'
)