chore: initialize tuhui repository
This commit is contained in:
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
|
||||
"""
|
||||
100
backend/app/ysm_sdk/pay.py
Normal file
100
backend/app/ysm_sdk/pay.py
Normal file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
发起支付模块
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import random
|
||||
import string
|
||||
import asyncio
|
||||
from .api import YsmPayApi
|
||||
|
||||
def generate_nonce_str(length=10):
|
||||
"""生成随机字符串"""
|
||||
chars = string.ascii_letters + string.digits
|
||||
return ''.join(random.choice(chars) for _ in range(length))
|
||||
|
||||
async def create_payment(appid, appsecret, order_id, description, amount,
|
||||
notify_url, nopay_url, callback_url, pay_type=1):
|
||||
"""
|
||||
创建支付请求(异步)
|
||||
|
||||
Args:
|
||||
appid: 支付通道ID
|
||||
appsecret: 密钥
|
||||
order_id: 商户订单号
|
||||
description: 订单描述
|
||||
amount: 支付金额(单位:分)
|
||||
notify_url: 异步回调地址
|
||||
nopay_url: 未支付跳转地址
|
||||
callback_url: 支付成功跳转地址
|
||||
pay_type: 支付类型,默认为1(微信内)
|
||||
|
||||
Returns:
|
||||
str: 支付链接
|
||||
"""
|
||||
# 构造支付请求数据
|
||||
data = {
|
||||
'appid': appid, # 支付通道ID
|
||||
'mch_orderid': order_id, # 商户网站订单号
|
||||
'description': description, # 订单标题
|
||||
'total': amount, # 订单金额(分)
|
||||
'notify_url': notify_url, # 异步通知地址
|
||||
'nopay_url': nopay_url, # 未支付跳转地址
|
||||
'callback_url': callback_url, # 支付成功跳转地址
|
||||
'time': int(time.time()), # 时间戳
|
||||
'nonce_str': f"abc{int(time.time())}", # 随机字符串
|
||||
'payType': pay_type, # 支付类型
|
||||
}
|
||||
|
||||
# 生成签名
|
||||
data['sign'] = YsmPayApi.hash_sign(data, appsecret)
|
||||
|
||||
# 发起支付请求
|
||||
url = 'https://www.yishoumi.cn/u/payment' # 支付网关
|
||||
response = await YsmPayApi.http_post(url, json.dumps(data))
|
||||
|
||||
# 解析响应
|
||||
result = json.loads(response) if response else None
|
||||
|
||||
# 返回支付链接
|
||||
if result and 'url' in result:
|
||||
return result['url']
|
||||
else:
|
||||
# 返回错误信息以便调用者处理
|
||||
if result and 'msg' in result:
|
||||
raise Exception(f"支付创建失败: {result['msg']} (错误代码: {result.get('code', 'unknown')})")
|
||||
else:
|
||||
raise Exception("支付创建失败: 无响应或响应格式错误")
|
||||
|
||||
async def main():
|
||||
# 示例使用
|
||||
appid = 'YSMcd16b45d' # 支付通道ID
|
||||
appsecret = '899850e778e8d2b53e4c4a4e88695688' # AppSecret
|
||||
|
||||
# 生成订单号
|
||||
order_id = f"123321{int(time.time())}"
|
||||
|
||||
# 创建支付
|
||||
pay_url = await create_payment(
|
||||
appid=appid,
|
||||
appsecret=appsecret,
|
||||
order_id=order_id,
|
||||
description="快充数据线",
|
||||
amount=100, # 1元
|
||||
notify_url="http://abc.com/notify.php",
|
||||
nopay_url="http://abc.com/notify.php",
|
||||
callback_url="http://abc.com/notify.php",
|
||||
pay_type=1 # 微信内支付
|
||||
)
|
||||
|
||||
if pay_url:
|
||||
print(f"支付链接: {pay_url}")
|
||||
else:
|
||||
print("创建支付失败")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
74
backend/app/ysm_sdk/query.py
Normal file
74
backend/app/ysm_sdk/query.py
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
查询订单模块
|
||||
"""
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
from .api import YsmPayApi
|
||||
|
||||
async def query_order(appid, mch_orderid=None, ysm_orderid=None):
|
||||
"""
|
||||
查询订单状态(异步)
|
||||
|
||||
Args:
|
||||
appid: 支付通道ID
|
||||
mch_orderid: 商户订单号(与ysm_orderid二选一)
|
||||
ysm_orderid: 易收米订单号(与mch_orderid二选一)
|
||||
|
||||
Returns:
|
||||
dict: 订单信息字典,包含状态等信息
|
||||
SUCCESS:支付成功
|
||||
REFUND:转入退款
|
||||
NOTPAY:未支付
|
||||
CLOSED:已关闭
|
||||
"""
|
||||
# 检查参数
|
||||
if not mch_orderid and not ysm_orderid:
|
||||
raise ValueError("商户订单号和易收米订单号必须提供一个")
|
||||
|
||||
# 构造请求数据
|
||||
data = {'appid': appid}
|
||||
|
||||
if mch_orderid:
|
||||
data['mch_orderid'] = mch_orderid
|
||||
elif ysm_orderid:
|
||||
data['ysm_orderid'] = ysm_orderid
|
||||
|
||||
# 请求URL
|
||||
url = 'https://www.yishoumi.cn/u/query' # 订单查询网关
|
||||
|
||||
try:
|
||||
# 发起请求
|
||||
response = await YsmPayApi.http_post(url, json.dumps(data))
|
||||
|
||||
# 解析响应
|
||||
result = json.loads(response) if response else None
|
||||
|
||||
if not result:
|
||||
raise Exception('服务器错误:' + str(response), 500)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"错误码:{getattr(e, 'code', 0)}, 错误信息:{str(e)}")
|
||||
return None
|
||||
|
||||
async def main():
|
||||
# 示例使用
|
||||
appid = '20********' # 支付通道ID
|
||||
|
||||
# 查询订单
|
||||
order_info = await query_order(
|
||||
appid=appid,
|
||||
mch_orderid='20230510505610', # 商户订单号
|
||||
)
|
||||
|
||||
if order_info:
|
||||
print(f"订单信息: {json.dumps(order_info, ensure_ascii=False, indent=2)}")
|
||||
else:
|
||||
print("查询订单失败")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
157
backend/app/ysm_sdk/tmp/README.md
Normal file
157
backend/app/ysm_sdk/tmp/README.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Ysm-SDK-python
|
||||
|
||||
易收米支付Python SDK,提供支付相关API的Python实现。
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
或者手动安装:
|
||||
|
||||
```bash
|
||||
pip install aiohttp fastapi uvicorn jinja2 python-multipart
|
||||
```
|
||||
|
||||
## 文件说明
|
||||
|
||||
- `api.py` - 基础API工具类,提供HTTP请求和签名方法
|
||||
- `pay.py` - 发起支付模块
|
||||
- `query.py` - 订单查询模块
|
||||
- `notify.py` - 支付回调处理模块
|
||||
- `demo.py` - 命令行演示脚本
|
||||
- `web_demo.py` - Web服务演示
|
||||
- `requirements.txt` - 依赖包列表
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 发起支付
|
||||
|
||||
```python
|
||||
from pay import create_payment
|
||||
|
||||
# 配置信息
|
||||
appid = '20********' # 支付通道ID
|
||||
appsecret = 'e605ac7******************4af5164' # AppSecret
|
||||
|
||||
# 创建支付
|
||||
pay_url = create_payment(
|
||||
appid=appid,
|
||||
appsecret=appsecret,
|
||||
order_id="123321123321",
|
||||
description="快充数据线",
|
||||
amount=100, # 1元
|
||||
notify_url="http://example.com/notify",
|
||||
nopay_url="http://example.com/cancel",
|
||||
callback_url="http://example.com/success",
|
||||
pay_type=1 # 微信内支付
|
||||
)
|
||||
|
||||
print(f"支付链接: {pay_url}")
|
||||
```
|
||||
|
||||
### 查询订单
|
||||
|
||||
```python
|
||||
from query import query_order
|
||||
|
||||
# 配置信息
|
||||
appid = '20********' # 支付通道ID
|
||||
|
||||
# 查询订单
|
||||
order_info = query_order(
|
||||
appid=appid,
|
||||
mch_orderid='202305105056', # 商户订单号
|
||||
)
|
||||
|
||||
print(f"订单信息: {order_info}")
|
||||
```
|
||||
|
||||
### 处理支付回调
|
||||
|
||||
```python
|
||||
from flask import Flask, request
|
||||
from notify import PaymentNotify
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route('/notify', methods=['POST'])
|
||||
def payment_notify():
|
||||
# 配置信息
|
||||
appid = '20********' # 支付通道ID
|
||||
appsecret = 'e605ac7******************4af5164' # AppSecret
|
||||
|
||||
# 创建通知处理器
|
||||
notify_handler = PaymentNotify(appid, appsecret)
|
||||
|
||||
# 获取请求数据
|
||||
request_data = request.get_data()
|
||||
|
||||
# 处理通知
|
||||
success, message = notify_handler.process(request_data)
|
||||
|
||||
if success:
|
||||
# 处理成功
|
||||
return message
|
||||
else:
|
||||
# 处理失败
|
||||
return {"error": message}, 400
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
||||
```
|
||||
|
||||
## 支付类型说明
|
||||
|
||||
- `pay_type=1`: 微信内支付
|
||||
- `pay_type=2`: 微信扫码支付
|
||||
- `pay_type=3`: 微信H5支付(非原生)
|
||||
- `pay_type=11`: 支付宝H5支付
|
||||
|
||||
## 演示程序
|
||||
|
||||
### 1. 命令行演示
|
||||
|
||||
运行完整的SDK功能演示:
|
||||
|
||||
```bash
|
||||
python demo.py
|
||||
```
|
||||
|
||||
这个演示会展示:
|
||||
- 创建支付订单
|
||||
- 查询订单状态
|
||||
- 处理支付回调
|
||||
- 不同支付类型说明
|
||||
|
||||
### 2. Web服务演示
|
||||
|
||||
启动Web演示服务:
|
||||
|
||||
```bash
|
||||
python web_demo.py
|
||||
```
|
||||
|
||||
然后访问 `http://localhost:8000` 查看:
|
||||
- 支付表单页面
|
||||
- 订单查询界面
|
||||
- 支付成功/取消页面
|
||||
- 支付回调处理接口
|
||||
|
||||
### 配置说明
|
||||
|
||||
在使用前请修改以下配置:
|
||||
|
||||
1. 在 `demo.py` 中修改:
|
||||
```python
|
||||
self.appid = 'your_appid' # 替换为你的AppID
|
||||
self.appsecret = 'your_appsecret' # 替换为你的AppSecret
|
||||
```
|
||||
|
||||
2. 在 `web_demo.py` 中修改:
|
||||
```python
|
||||
APPID = 'your_appid'
|
||||
APPSECRET = 'your_appsecret'
|
||||
NOTIFY_URL = "http://your-domain.com/notify" # 替换为你的回调地址
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user