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

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# frontend
frontend/node_modules/
frontend/dist/
frontend/playwright-report/
frontend/.vite/
frontend/.cache/
# backend
backend/.env
backend/__pycache__/
backend/app/__pycache__/
backend/*.db
backend/uploads/
backend/.pytest_cache/
backend/.mypy_cache/
backend/.ruff_cache/
# runtime data
uploads/
*.log
*.pid
# local/editor
.vscode/
.idea/
.DS_Store
Thumbs.db
# legacy/backup project not in active deployment
nitu/

12
backend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
COPY . .
EXPOSE 8002
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002"]

128
backend/README.md Normal file
View File

@@ -0,0 +1,128 @@
# 爱设计后端 API
基于 FastAPI 的付费下载平台后端
## 功能特性
- ✅ 用户注册/登录JWT认证
- ✅ 作品浏览/搜索
- ✅ 付费下载(易收米支付)
- ✅ 图片水印处理
- ✅ 缩略图生成
- ✅ 订单管理
- ✅ 支付回调处理
- ✅ 订单状态查询
- ✅ Docker 容器化部署
## 快速开始
### 1. 使用 Docker Compose推荐
```bash
# 启动所有服务
docker-compose up -d
# 查看日志
docker-compose logs -f
# 停止服务
docker-compose down
```
### 2. 本地开发
```bash
# 安装依赖
pip install -r requirements.txt
# 复制环境变量文件
cp .env.example .env
# 修改 .env 中的配置
# 启动服务
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
## API 文档
启动后访问:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## 目录结构
```
backend/
├── app/
│ ├── api/ # API 路由
│ ├── core/ # 核心配置
│ ├── models/ # 数据库模型
│ ├── schemas/ # Pydantic 模型
│ ├── services/ # 业务逻辑
│ ├── utils/ # 工具函数
│ └── main.py # 入口文件
├── uploads/ # 文件上传目录
│ ├── original/ # 原图(高清)
│ ├── watermarked/ # 带水印图
│ └── thumbnail/ # 缩略图
├── requirements.txt
├── Dockerfile
└── docker-compose.yml
```
## 环境变量
查看 `.env.example` 文件了解所有配置项
## 部署到生产环境
### 使用 Caddy
在服务器上创建 Caddyfile
```
api.aishej.com {
reverse_proxy localhost:8000
}
```
然后启动 Caddy
```bash
caddy start
```
## 接口列表
### 认证
- POST `/api/auth/register` - 用户注册
- POST `/api/auth/login` - 用户登录
### 作品
- GET `/api/works` - 作品列表
- GET `/api/works/{id}` - 作品详情
### 订单
- POST `/api/orders/create` - 创建订单
- POST `/api/orders/pay/{id}` - 支付订单(模拟支付,仅测试用)
- GET `/api/orders/my` - 我的订单
### 支付(易收米)
- POST `/api/payment/create/{order_id}` - 创建支付链接(真实支付)
- POST `/api/payment/notify` - 支付回调接口(易收米异步通知)
- GET `/api/payment/query/{order_number}` - 查询订单支付状态
- POST `/api/payment/cancel/{order_id}` - 取消订单
## 后续开发
- [ ] 接入真实支付接口(支付宝/微信)
- [ ] 作品上传管理
- [ ] 管理后台
- [ ] 数据统计
- [ ] Redis 缓存
- [ ] 日志系统
## License
MIT

3
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
FastAPI 后端入口文件的 __init__.py
"""

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'
)

View File

@@ -0,0 +1 @@
"""核心模块"""

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.

View File

@@ -0,0 +1,51 @@
from pydantic_settings import BaseSettings
from typing import List
class Settings(BaseSettings):
# 项目信息
PROJECT_NAME: str = "爱设计 API"
VERSION: str = "1.0.0"
API_PREFIX: str = "/api"
# 数据库配置
DATABASE_URL: str = "sqlite:///./test.db" # 使用SQLite进行测试
# DATABASE_URL: str = "mysql+pymysql://root:password@localhost:3306/aishej" # MySQL配置
# JWT 配置
SECRET_KEY: str = "your-secret-key-change-this"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # 24小时
# 文件配置
UPLOAD_DIR: str = "./uploads"
MAX_FILE_SIZE: int = 52428800 # 50MB
# 水印配置
WATERMARK_TEXT: str = "爱设计 AiSheji.com"
WATERMARK_OPACITY: int = 128
# 缩略图配置
THUMBNAIL_SIZE: int = 400
THUMBNAIL_QUALITY: int = 85
# CORS 配置(生产环境配置为具体域名)
ALLOWED_ORIGINS: List[str] = [
"https://backend.huijie168.uk",
"https://app.huijie168.uk",
"https://run.huijie168.uk",
"https://huijie168.uk",
"https://www.huijie168.uk"
]
# 易收米支付配置
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" # 未支付跳转地址
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()

View File

@@ -0,0 +1,26 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from .config import settings
# 创建数据库引擎
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_recycle=3600,
echo=True # 开发环境打印 SQL
)
# 创建 Session 工厂
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 创建基类
Base = declarative_base()
# 依赖注入:获取数据库会话
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,54 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from .config import settings
import hashlib
# 密码加密上下文 - 禁用bcrypt的wrap bug检测以避免72字节限制错误
pwd_context = CryptContext(
schemes=["bcrypt"],
deprecated="auto",
bcrypt__ident="2b", # 使用2b版本跳过wrap bug检测
)
def _truncate_password(password: str) -> str:
"""
截断密码以适应bcrypt的72字节限制
对于超长密码使用SHA256哈希确保唯一性
"""
password_bytes = password.encode('utf-8')
if len(password_bytes) <= 72:
return password
# 对超长密码进行hash确保唯一性且在72字节以内
return hashlib.sha256(password_bytes).hexdigest()
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
safe_password = _truncate_password(plain_password)
return pwd_context.verify(safe_password, hashed_password)
def get_password_hash(password: str) -> str:
"""加密密码bcrypt限制最多72字节"""
safe_password = _truncate_password(password)
return pwd_context.hash(safe_password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""创建 JWT Token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_access_token(token: str) -> Optional[dict]:
"""解析 JWT Token"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
return None

65
backend/app/main.py Normal file
View File

@@ -0,0 +1,65 @@
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.core.config import settings
from app.core.database import Base, engine
from app.api import auth, works, orders, payment, upload
# 创建数据库表
Base.metadata.create_all(bind=engine)
# 创建 FastAPI 应用
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
description="图绘 - 设计作品交易平台 API"
)
# 配置 CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],
expose_headers=["*"],
max_age=600,
)
# 添加全局 CORS 中间件
@app.middleware("http")
async def add_cors_headers(request: Request, call_next):
response = await call_next(request)
origin = request.headers.get("origin", "")
if origin in settings.ALLOWED_ORIGINS:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "*"
if request.method == "OPTIONS":
response.headers["Access-Control-Max-Age"] = "600"
return response
# 挂载静态文件目录
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
# 注册路由
app.include_router(auth.router, prefix=settings.API_PREFIX)
app.include_router(works.router, prefix=settings.API_PREFIX)
app.include_router(orders.router, prefix=settings.API_PREFIX)
app.include_router(payment.router, prefix=settings.API_PREFIX)
app.include_router(upload.router, prefix=settings.API_PREFIX)
@app.get("/")
def root():
return {
"message": "欢迎使用图绘 API",
"version": settings.VERSION,
"docs": "/docs"
}
@app.get("/health")
def health_check():
return {"status": "healthy"}

View File

@@ -0,0 +1 @@
"""数据库模型"""

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.

View File

@@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, DateTime, ForeignKey
from sqlalchemy.sql import func
from app.core.database import Base
class DownloadRecord(Base):
__tablename__ = "download_records"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
work_id = Column(Integer, ForeignKey("works.id"), nullable=False, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False, index=True)
downloaded_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,28 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Enum
from sqlalchemy.sql import func
from app.core.database import Base
import enum
class OrderStatus(str, enum.Enum):
PENDING = "pending" # 待支付
PAID = "paid" # 已支付
CANCELLED = "cancelled" # 已取消
REFUNDED = "refunded" # 已退款
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
order_no = Column(String(100), unique=True, index=True, nullable=False) # 订单号
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
work_id = Column(Integer, ForeignKey("works.id"), nullable=False, index=True)
amount = Column(Float, nullable=False) # 金额
payment_method = Column(String(50)) # 支付方式alipay, wechat, balance
status = Column(Enum(OrderStatus), default=OrderStatus.PENDING, index=True)
paid_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,15 @@
from sqlalchemy import Column, Integer, String, DateTime, Float
from sqlalchemy.sql import func
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
phone = Column(String(20), unique=True, index=True, nullable=False) # 手机号作为唯一标识
password_hash = Column(String(255), nullable=False)
nickname = Column(String(100), nullable=True)
avatar = Column(String(500), nullable=True)
balance = Column(Float, default=0.0) # 账户余额
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,33 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, Text
from sqlalchemy.sql import func
from app.core.database import Base
class Work(Base):
__tablename__ = "works"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False, index=True)
category = Column(String(100), index=True)
designer = Column(String(100))
level = Column(Integer, default=1)
level_text = Column(String(50))
# 文件路径
original_image = Column(String(500)) # 原图(高清)
watermarked_image = Column(String(500)) # 带水印图(详情页显示)
thumbnail_image = Column(String(500)) # 缩略图(列表显示)
# 价格
price = Column(Float, default=0.0) # 下载价格(元)
# 统计
views = Column(Integer, default=0) # 浏览量
downloads = Column(Integer, default=0) # 下载量
collects = Column(Integer, default=0) # 收藏量
# 描述
description = Column(Text, nullable=True)
tags = Column(String(500), nullable=True) # JSON 格式存储标签
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1 @@
"""Pydantic Schemas"""

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.

View File

@@ -0,0 +1,30 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
# 创建订单
class OrderCreate(BaseModel):
work_id: int
payment_method: str = "balance" # balance, alipay, wechat
# 订单响应
class OrderResponse(BaseModel):
id: int
order_no: str
user_id: int
work_id: int
amount: float
payment_method: str
status: str
paid_at: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True
# 支付响应
class PaymentResponse(BaseModel):
success: bool
message: str
order_no: str
download_url: Optional[str] = None

View File

@@ -0,0 +1,62 @@
from pydantic import BaseModel, validator
from typing import Optional
from datetime import datetime
import re
# 用户注册
class UserRegister(BaseModel):
phone: str # 手机号必填,作为唯一标识
password: str
nickname: Optional[str] = None
@validator('phone')
def validate_phone(cls, v):
"""验证手机号格式11位数字"""
if not v:
raise ValueError('手机号不能为空')
if not re.match(r'^1[3-9]\d{9}$', v):
raise ValueError('手机号格式错误请输入11位有效手机号')
return v
@validator('password')
def validate_password(cls, v):
"""验证密码"""
if not v:
raise ValueError('密码不能为空')
if len(v) < 6:
raise ValueError('密码长度不能少于6位')
if len(v) > 72:
raise ValueError('密码长度不能超过72位')
return v
# 用户登录
class UserLogin(BaseModel):
phone: str # 手机号登录
password: str
@validator('phone')
def validate_phone(cls, v):
"""验证手机号格式11位数字"""
if not v:
raise ValueError('手机号不能为空')
if not re.match(r'^1[3-9]\d{9}$', v):
raise ValueError('手机号格式错误请输入11位有效手机号')
return v
# 用户响应
class UserResponse(BaseModel):
id: int
phone: str
nickname: Optional[str]
avatar: Optional[str]
balance: float
created_at: datetime
class Config:
from_attributes = True
# Token 响应
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserResponse

View File

@@ -0,0 +1,50 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
# 作品创建
class WorkCreate(BaseModel):
title: str
category: str
designer: str
level: int = 1
level_text: str = "设计爱好者"
price: float # 下载价格
description: Optional[str] = None
tags: Optional[str] = None
# 作品更新
class WorkUpdate(BaseModel):
title: Optional[str] = None
price: Optional[float] = None
description: Optional[str] = None
# 作品响应
class WorkResponse(BaseModel):
id: int
title: str
category: str
designer: str
level: int
level_text: str
thumbnail_image: Optional[str]
watermarked_image: Optional[str]
price: float
views: int
downloads: int
collects: int
description: Optional[str]
tags: Optional[str]
created_at: datetime
class Config:
from_attributes = True
# 作品列表响应
class WorkListResponse(BaseModel):
total: int
items: List[WorkResponse]

View File

@@ -0,0 +1 @@
"""工具函数"""

127
backend/app/utils/image.py Normal file
View File

@@ -0,0 +1,127 @@
from PIL import Image, ImageDraw, ImageFont
import os
from pathlib import Path
from app.core.config import settings
def add_watermark(input_path: str, output_path: str, text: str = None) -> str:
"""
给图片添加水印
Args:
input_path: 输入图片路径
output_path: 输出图片路径
text: 水印文字,默认使用配置
Returns:
输出图片路径
"""
if text is None:
text = settings.WATERMARK_TEXT
# 打开原图
image = Image.open(input_path).convert("RGBA")
width, height = image.size
# 创建水印层
watermark = Image.new("RGBA", image.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(watermark)
# 计算字体大小(根据图片大小自适应)
font_size = int(min(width, height) / 20)
try:
# 尝试使用系统字体
font = ImageFont.truetype("arial.ttf", font_size)
except:
# 如果没有找到字体,使用默认字体
font = ImageFont.load_default()
# 获取文字大小
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
# 绘制水印(半透明白色文字 + 黑色描边)
# 先绘制黑色描边
for adj_x in range(-2, 3):
for adj_y in range(-2, 3):
draw.text((x + adj_x, y + adj_y), text, font=font, fill=(0, 0, 0, settings.WATERMARK_OPACITY))
# 再绘制白色文字
draw.text((x, y), text, font=font, fill=(255, 255, 255, settings.WATERMARK_OPACITY))
# 合并图层
watermarked = Image.alpha_composite(image, watermark)
# 转换为 RGB 并保存
watermarked_rgb = watermarked.convert("RGB")
# 确保输出目录存在
os.makedirs(os.path.dirname(output_path), exist_ok=True)
watermarked_rgb.save(output_path, "JPEG", quality=95)
return output_path
def create_thumbnail(input_path: str, output_path: str, size: int = None) -> str:
"""
创建缩略图
Args:
input_path: 输入图片路径
output_path: 输出图片路径
size: 缩略图最大尺寸,默认使用配置
Returns:
输出图片路径
"""
if size is None:
size = settings.THUMBNAIL_SIZE
# 打开原图
image = Image.open(input_path)
# 计算缩略图尺寸(保持宽高比)
image.thumbnail((size, size), Image.Resampling.LANCZOS)
# 确保输出目录存在
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# 保存缩略图
image.save(output_path, "JPEG", quality=settings.THUMBNAIL_QUALITY)
return output_path
def process_uploaded_image(original_path: str, work_id: int) -> dict:
"""
处理上传的图片:生成水印图和缩略图
Args:
original_path: 原图路径
work_id: 作品ID
Returns:
包含三个图片路径的字典
"""
# 生成文件名
filename = f"{work_id}.jpg"
# 定义输出路径
watermarked_path = os.path.join(settings.UPLOAD_DIR, "watermarked", filename)
thumbnail_path = os.path.join(settings.UPLOAD_DIR, "thumbnail", filename)
# 生成带水印的图片
add_watermark(original_path, watermarked_path)
# 生成缩略图
create_thumbnail(original_path, thumbnail_path)
return {
"original": original_path,
"watermarked": watermarked_path,
"thumbnail": thumbnail_path
}

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""易收米支付SDK"""
from .pay import create_payment
from .query import query_order
from .notify import PaymentNotify
__all__ = ['create_payment', 'query_order', 'PaymentNotify']

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.

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import json
import hashlib
import aiohttp
class YsmPayApi:
"""易收米支付API工具类"""
@staticmethod
async def http_post(url, data):
"""
发送异步HTTP POST请求
Args:
url: 请求地址
data: 请求数据JSON字符串
Returns:
str: 响应内容
"""
headers = {
'Content-Type': 'application/json; charset=UTF-8',
'Accept': 'application/json',
'User-Agent': '*/*',
'Authorization': 'WECHATPAY2-SHA256-RSA2048 '
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, data=data, headers=headers, ssl=False, timeout=30) as response:
return await response.text()
except Exception as e:
print(f"Error: {str(e)}")
return None
@staticmethod
def hash_sign(data, secret):
"""
生成签名
Args:
data: 要签名的数据字典
secret: 密钥
Returns:
str: 签名字符串
"""
# 按键名升序排序
sorted_data = dict(sorted(data.items()))
# 构造签名字符串
sign_str = ""
for key, value in sorted_data.items():
# 跳过hash字段和空值
if key == 'hash' or value is None or value == '':
continue
# 添加分隔符
if sign_str:
sign_str += '&'
# 拼接键值对
sign_str += f"{key}={value}"
# 添加密钥并计算SHA256哈希
sign_str += secret
return hashlib.sha256(sign_str.encode('utf-8')).hexdigest()

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
支付成功异步回调处理模块
当用户支付成功后,支付平台会把订单支付信息异步请求到回调接口
"""
import json
import asyncio
from .api import YsmPayApi
class PaymentNotify:
"""支付通知处理类"""
def __init__(self, appid, appsecret):
"""
初始化
Args:
appid: 支付通道ID
appsecret: 密钥
"""
self.appid = appid
self.appsecret = appsecret
async def process(self, request_data):
"""
处理支付通知(异步)
Args:
request_data: 请求数据(字符串或字典)
Returns:
tuple: (是否成功, 消息)
"""
# 解析请求数据
if isinstance(request_data, str):
try:
data = json.loads(request_data)
except:
return False, "数据格式错误"
else:
data = request_data
# 验证签名
calc_hash = YsmPayApi.hash_sign(data, self.appsecret)
if data.get('hash') != calc_hash:
return False, "验签失败,签名错误"
# 获取商户订单号
mch_orderid = data.get('mch_orderid')
# 处理支付结果
if data.get('state') == 'SUCCESS':
# 在这里处理订单业务逻辑
# 注意:平台可能会多次调用本接口,请确保订单不会被重复处理
# 示例:订单处理逻辑,这里可以使用异步数据库操作
"""
order = await find_order_by_id(mch_orderid)
if order and order.status != 'paid':
await update_order_status(mch_orderid, 'paid')
# 其他业务逻辑...
"""
return True, "success"
else:
# 处理未支付的情况
return False, "订单未支付"
# FastAPI Web应用示例
"""
from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
app = FastAPI()
@app.post("/notify")
async def payment_notify(request: Request):
# 配置信息
appid = '20********' # 支付通道ID
appsecret = 'e605ac7******************4af5164' # AppSecret
# 创建通知处理器
notify_handler = PaymentNotify(appid, appsecret)
# 获取请求数据
request_data = await request.body()
# 处理通知
success, message = await notify_handler.process(request_data)
if success:
return Response(content=message)
else:
return JSONResponse(content={"error": message}, status_code=400)
# 启动命令: uvicorn notify:app --reload
"""

Some files were not shown because too many files have changed in this diff Show More