chore: initialize tuhui repository
This commit is contained in:
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal 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
12
backend/Dockerfile
Normal 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
128
backend/README.md
Normal 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
3
backend/app/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
FastAPI 后端入口文件的 __init__.py
|
||||||
|
"""
|
||||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""API 路由"""
|
||||||
BIN
backend/app/api/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
backend/app/api/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/app/api/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/api/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/auth.cpython-310.pyc
Normal file
BIN
backend/app/api/__pycache__/auth.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/auth.cpython-311.pyc
Normal file
BIN
backend/app/api/__pycache__/auth.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/auth.cpython-312.pyc
Normal file
BIN
backend/app/api/__pycache__/auth.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/orders.cpython-310.pyc
Normal file
BIN
backend/app/api/__pycache__/orders.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/orders.cpython-311.pyc
Normal file
BIN
backend/app/api/__pycache__/orders.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/orders.cpython-312.pyc
Normal file
BIN
backend/app/api/__pycache__/orders.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/payment.cpython-310.pyc
Normal file
BIN
backend/app/api/__pycache__/payment.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/payment.cpython-311.pyc
Normal file
BIN
backend/app/api/__pycache__/payment.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/payment.cpython-312.pyc
Normal file
BIN
backend/app/api/__pycache__/payment.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/upload.cpython-311.pyc
Normal file
BIN
backend/app/api/__pycache__/upload.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/works.cpython-310.pyc
Normal file
BIN
backend/app/api/__pycache__/works.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/works.cpython-311.pyc
Normal file
BIN
backend/app/api/__pycache__/works.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/works.cpython-312.pyc
Normal file
BIN
backend/app/api/__pycache__/works.cpython-312.pyc
Normal file
Binary file not shown.
79
backend/app/api/auth.py
Normal file
79
backend/app/api/auth.py
Normal 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
171
backend/app/api/orders.py
Normal 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
267
backend/app/api/payment.py
Normal 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
250
backend/app/api/upload.py
Normal 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
|
||||||
|
}
|
||||||
256
backend/app/api/upload.py.backup
Normal file
256
backend/app/api/upload.py.backup
Normal 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
157
backend/app/api/works.py
Normal 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'
|
||||||
|
)
|
||||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""核心模块"""
|
||||||
BIN
backend/app/core/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
backend/app/core/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/app/core/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/config.cpython-310.pyc
Normal file
BIN
backend/app/core/__pycache__/config.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/config.cpython-311.pyc
Normal file
BIN
backend/app/core/__pycache__/config.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/config.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/database.cpython-310.pyc
Normal file
BIN
backend/app/core/__pycache__/database.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/database.cpython-311.pyc
Normal file
BIN
backend/app/core/__pycache__/database.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/database.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/database.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/security.cpython-310.pyc
Normal file
BIN
backend/app/core/__pycache__/security.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/security.cpython-311.pyc
Normal file
BIN
backend/app/core/__pycache__/security.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/core/__pycache__/security.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/security.cpython-312.pyc
Normal file
Binary file not shown.
51
backend/app/core/config.py
Normal file
51
backend/app/core/config.py
Normal 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()
|
||||||
26
backend/app/core/database.py
Normal file
26
backend/app/core/database.py
Normal 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()
|
||||||
54
backend/app/core/security.py
Normal file
54
backend/app/core/security.py
Normal 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
65
backend/app/main.py
Normal 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"}
|
||||||
1
backend/app/models/__init__.py
Normal file
1
backend/app/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""数据库模型"""
|
||||||
BIN
backend/app/models/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
backend/app/models/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/app/models/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/download.cpython-310.pyc
Normal file
BIN
backend/app/models/__pycache__/download.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/download.cpython-311.pyc
Normal file
BIN
backend/app/models/__pycache__/download.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/download.cpython-312.pyc
Normal file
BIN
backend/app/models/__pycache__/download.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/order.cpython-310.pyc
Normal file
BIN
backend/app/models/__pycache__/order.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/order.cpython-311.pyc
Normal file
BIN
backend/app/models/__pycache__/order.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/order.cpython-312.pyc
Normal file
BIN
backend/app/models/__pycache__/order.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/user.cpython-310.pyc
Normal file
BIN
backend/app/models/__pycache__/user.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/user.cpython-311.pyc
Normal file
BIN
backend/app/models/__pycache__/user.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/user.cpython-312.pyc
Normal file
BIN
backend/app/models/__pycache__/user.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/work.cpython-310.pyc
Normal file
BIN
backend/app/models/__pycache__/work.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/work.cpython-311.pyc
Normal file
BIN
backend/app/models/__pycache__/work.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/work.cpython-312.pyc
Normal file
BIN
backend/app/models/__pycache__/work.cpython-312.pyc
Normal file
Binary file not shown.
13
backend/app/models/download.py
Normal file
13
backend/app/models/download.py
Normal 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())
|
||||||
28
backend/app/models/order.py
Normal file
28
backend/app/models/order.py
Normal 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())
|
||||||
15
backend/app/models/user.py
Normal file
15
backend/app/models/user.py
Normal 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())
|
||||||
33
backend/app/models/work.py
Normal file
33
backend/app/models/work.py
Normal 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())
|
||||||
1
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Pydantic Schemas"""
|
||||||
BIN
backend/app/schemas/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
backend/app/schemas/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/schemas/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/app/schemas/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/schemas/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/schemas/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/schemas/__pycache__/order.cpython-310.pyc
Normal file
BIN
backend/app/schemas/__pycache__/order.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/schemas/__pycache__/order.cpython-311.pyc
Normal file
BIN
backend/app/schemas/__pycache__/order.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/schemas/__pycache__/order.cpython-312.pyc
Normal file
BIN
backend/app/schemas/__pycache__/order.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/schemas/__pycache__/user.cpython-310.pyc
Normal file
BIN
backend/app/schemas/__pycache__/user.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/schemas/__pycache__/user.cpython-311.pyc
Normal file
BIN
backend/app/schemas/__pycache__/user.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/schemas/__pycache__/user.cpython-312.pyc
Normal file
BIN
backend/app/schemas/__pycache__/user.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/schemas/__pycache__/work.cpython-310.pyc
Normal file
BIN
backend/app/schemas/__pycache__/work.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/schemas/__pycache__/work.cpython-311.pyc
Normal file
BIN
backend/app/schemas/__pycache__/work.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/schemas/__pycache__/work.cpython-312.pyc
Normal file
BIN
backend/app/schemas/__pycache__/work.cpython-312.pyc
Normal file
Binary file not shown.
30
backend/app/schemas/order.py
Normal file
30
backend/app/schemas/order.py
Normal 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
|
||||||
62
backend/app/schemas/user.py
Normal file
62
backend/app/schemas/user.py
Normal 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
|
||||||
50
backend/app/schemas/work.py
Normal file
50
backend/app/schemas/work.py
Normal 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]
|
||||||
1
backend/app/utils/__init__.py
Normal file
1
backend/app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""工具函数"""
|
||||||
127
backend/app/utils/image.py
Normal file
127
backend/app/utils/image.py
Normal 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
|
||||||
|
}
|
||||||
10
backend/app/ysm_sdk/__init__.py
Normal file
10
backend/app/ysm_sdk/__init__.py
Normal 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']
|
||||||
BIN
backend/app/ysm_sdk/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
backend/app/ysm_sdk/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/ysm_sdk/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/app/ysm_sdk/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/ysm_sdk/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/ysm_sdk/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/ysm_sdk/__pycache__/api.cpython-310.pyc
Normal file
BIN
backend/app/ysm_sdk/__pycache__/api.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/ysm_sdk/__pycache__/api.cpython-311.pyc
Normal file
BIN
backend/app/ysm_sdk/__pycache__/api.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/ysm_sdk/__pycache__/api.cpython-312.pyc
Normal file
BIN
backend/app/ysm_sdk/__pycache__/api.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/ysm_sdk/__pycache__/notify.cpython-310.pyc
Normal file
BIN
backend/app/ysm_sdk/__pycache__/notify.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/ysm_sdk/__pycache__/notify.cpython-311.pyc
Normal file
BIN
backend/app/ysm_sdk/__pycache__/notify.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/ysm_sdk/__pycache__/notify.cpython-312.pyc
Normal file
BIN
backend/app/ysm_sdk/__pycache__/notify.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/ysm_sdk/__pycache__/pay.cpython-310.pyc
Normal file
BIN
backend/app/ysm_sdk/__pycache__/pay.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/ysm_sdk/__pycache__/pay.cpython-311.pyc
Normal file
BIN
backend/app/ysm_sdk/__pycache__/pay.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/ysm_sdk/__pycache__/pay.cpython-312.pyc
Normal file
BIN
backend/app/ysm_sdk/__pycache__/pay.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/ysm_sdk/__pycache__/query.cpython-310.pyc
Normal file
BIN
backend/app/ysm_sdk/__pycache__/query.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/ysm_sdk/__pycache__/query.cpython-311.pyc
Normal file
BIN
backend/app/ysm_sdk/__pycache__/query.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/app/ysm_sdk/__pycache__/query.cpython-312.pyc
Normal file
BIN
backend/app/ysm_sdk/__pycache__/query.cpython-312.pyc
Normal file
Binary file not shown.
69
backend/app/ysm_sdk/api.py
Normal file
69
backend/app/ysm_sdk/api.py
Normal 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()
|
||||||
100
backend/app/ysm_sdk/notify.py
Normal file
100
backend/app/ysm_sdk/notify.py
Normal 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
Reference in New Issue
Block a user