haha
This commit is contained in:
@@ -159,6 +159,7 @@ class ProductAdminOut(Schema):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
images: List[str] = []
|
||||
category_id: int
|
||||
category_name: Optional[str] = None
|
||||
status: str
|
||||
@@ -186,6 +187,12 @@ class ProductReviewIn(Schema):
|
||||
reject_reason: Optional[str] = None
|
||||
|
||||
|
||||
class ProductImagesIn(Schema):
|
||||
"""Product images update input schema."""
|
||||
images: List[str] = []
|
||||
image: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/products/pending/", response=List[ProductAdminOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def list_pending_products(request):
|
||||
@@ -233,3 +240,25 @@ def review_product(request, product_id: int, data: ProductReviewIn):
|
||||
product.save()
|
||||
|
||||
return product
|
||||
|
||||
|
||||
@router.put("/products/{product_id}/images/", response=ProductAdminOut, auth=JWTAuth())
|
||||
def update_product_images(request, product_id: int, data: ProductImagesIn):
|
||||
require_admin(request.auth)
|
||||
try:
|
||||
product = Product.objects.select_related("category", "submitted_by").get(id=product_id)
|
||||
except Product.DoesNotExist:
|
||||
raise HttpError(404, "商品不存在")
|
||||
|
||||
max_images = 6
|
||||
images = [url.strip() for url in (data.images or []) if url and url.strip()]
|
||||
image = (data.image or "").strip() or (images[0] if images else None)
|
||||
if image and image not in images:
|
||||
images.insert(0, image)
|
||||
if len(images) > max_images:
|
||||
raise HttpError(400, f"最多上传{max_images}张图片")
|
||||
|
||||
product.image = image or None
|
||||
product.images = images
|
||||
product.save(update_fields=["image", "images", "updated_at"])
|
||||
return product
|
||||
|
||||
@@ -4,10 +4,15 @@ Products API routes for categories, websites, products and prices.
|
||||
from typing import List, Optional
|
||||
import re
|
||||
import time
|
||||
import os
|
||||
import uuid
|
||||
import mimetypes
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import csv
|
||||
import io
|
||||
from ninja import Router, Query, File
|
||||
from ninja.errors import HttpError
|
||||
from ninja.errors import HttpError
|
||||
from ninja.files import UploadedFile
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
from ninja.pagination import paginate, PageNumberPagination
|
||||
@@ -15,6 +20,8 @@ from django.conf import settings
|
||||
from django.db.models import Count, Min, Max, Q, Prefetch, F
|
||||
from django.db import transaction, IntegrityError
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.files.storage import default_storage
|
||||
from django.views.decorators.cache import cache_page
|
||||
|
||||
from .models import Category, Website, Product, ProductPrice
|
||||
@@ -26,6 +33,7 @@ from .schemas import (
|
||||
ProductSearchFilter,
|
||||
ImportResultOut,
|
||||
MyProductOut,
|
||||
UploadImageOut,
|
||||
)
|
||||
from apps.favorites.models import Favorite
|
||||
|
||||
@@ -33,6 +41,8 @@ router = Router()
|
||||
category_router = Router()
|
||||
website_router = Router()
|
||||
|
||||
MAX_PRODUCT_IMAGES = 6
|
||||
|
||||
|
||||
# ==================== Category Routes ====================
|
||||
|
||||
@@ -355,51 +365,67 @@ def list_products(request, filters: ProductFilter = Query(...)):
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def get_product(request, product_id: int):
|
||||
"""Get product by ID."""
|
||||
return get_object_or_404(Product, id=product_id)
|
||||
# 只返回已审核通过的商品
|
||||
return get_object_or_404(Product, id=product_id, status='approved')
|
||||
|
||||
|
||||
@router.get("/{product_id}/with-prices", response=ProductWithPricesOut)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def get_product_with_prices(request, product_id: int):
|
||||
"""Get product with all prices from different websites."""
|
||||
product = get_object_or_404(Product, id=product_id)
|
||||
|
||||
prices = ProductPrice.objects.filter(product=product).select_related('website')
|
||||
price_list = []
|
||||
|
||||
for pp in prices:
|
||||
price_list.append(ProductPriceOut(
|
||||
id=pp.id,
|
||||
product_id=pp.product_id,
|
||||
website_id=pp.website_id,
|
||||
website_name=pp.website.name,
|
||||
website_logo=pp.website.logo,
|
||||
price=pp.price,
|
||||
original_price=pp.original_price,
|
||||
currency=pp.currency,
|
||||
url=pp.url,
|
||||
in_stock=pp.in_stock,
|
||||
last_checked=pp.last_checked,
|
||||
))
|
||||
|
||||
# Calculate price range
|
||||
price_stats = prices.aggregate(
|
||||
lowest=Min('price'),
|
||||
highest=Max('price')
|
||||
)
|
||||
|
||||
return ProductWithPricesOut(
|
||||
id=product.id,
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
image=product.image,
|
||||
category_id=product.category_id,
|
||||
created_at=product.created_at,
|
||||
updated_at=product.updated_at,
|
||||
prices=price_list,
|
||||
lowest_price=price_stats['lowest'],
|
||||
highest_price=price_stats['highest'],
|
||||
)
|
||||
try:
|
||||
product = get_object_or_404(Product, id=product_id)
|
||||
|
||||
prices = ProductPrice.objects.filter(product=product).select_related('website')
|
||||
price_list = []
|
||||
|
||||
for pp in prices:
|
||||
website_name = None
|
||||
website_logo = None
|
||||
try:
|
||||
if pp.website_id:
|
||||
website_name = pp.website.name
|
||||
website_logo = pp.website.logo
|
||||
except ObjectDoesNotExist:
|
||||
# 数据不一致时避免直接报错
|
||||
website_name = None
|
||||
website_logo = None
|
||||
|
||||
price_list.append(ProductPriceOut(
|
||||
id=pp.id,
|
||||
product_id=pp.product_id,
|
||||
website_id=pp.website_id,
|
||||
website_name=website_name,
|
||||
website_logo=website_logo,
|
||||
price=pp.price,
|
||||
original_price=pp.original_price,
|
||||
currency=pp.currency,
|
||||
url=pp.url,
|
||||
in_stock=pp.in_stock,
|
||||
last_checked=pp.last_checked,
|
||||
))
|
||||
|
||||
# Calculate price range
|
||||
price_stats = prices.aggregate(
|
||||
lowest=Min('price'),
|
||||
highest=Max('price')
|
||||
)
|
||||
|
||||
return ProductWithPricesOut(
|
||||
id=product.id,
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
image=product.image,
|
||||
images=list(product.images or []),
|
||||
category_id=product.category_id,
|
||||
created_at=product.created_at,
|
||||
updated_at=product.updated_at,
|
||||
prices=price_list,
|
||||
lowest_price=price_stats['lowest'],
|
||||
highest_price=price_stats['highest'],
|
||||
)
|
||||
except Exception as exc:
|
||||
# 方便定位500具体原因(开发环境)
|
||||
raise HttpError(500, f"with-prices failed: {exc}")
|
||||
|
||||
|
||||
@router.get("/search/", response=List[ProductWithPricesOut])
|
||||
@@ -446,22 +472,31 @@ def search_products(request, q: str, filters: ProductSearchFilter = Query(...)):
|
||||
result = []
|
||||
for product in products:
|
||||
prices = list(product.prices.all())
|
||||
price_list = [
|
||||
ProductPriceOut(
|
||||
price_list = []
|
||||
for pp in prices:
|
||||
website_name = None
|
||||
website_logo = None
|
||||
try:
|
||||
if pp.website_id:
|
||||
website_name = pp.website.name
|
||||
website_logo = pp.website.logo
|
||||
except ObjectDoesNotExist:
|
||||
website_name = None
|
||||
website_logo = None
|
||||
|
||||
price_list.append(ProductPriceOut(
|
||||
id=pp.id,
|
||||
product_id=pp.product_id,
|
||||
website_id=pp.website_id,
|
||||
website_name=pp.website.name,
|
||||
website_logo=pp.website.logo,
|
||||
website_name=website_name,
|
||||
website_logo=website_logo,
|
||||
price=pp.price,
|
||||
original_price=pp.original_price,
|
||||
currency=pp.currency,
|
||||
url=pp.url,
|
||||
in_stock=pp.in_stock,
|
||||
last_checked=pp.last_checked,
|
||||
)
|
||||
for pp in prices
|
||||
]
|
||||
))
|
||||
lowest_price = min((pp.price for pp in prices), default=None)
|
||||
highest_price = max((pp.price for pp in prices), default=None)
|
||||
|
||||
@@ -470,6 +505,7 @@ def search_products(request, q: str, filters: ProductSearchFilter = Query(...)):
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
image=product.image,
|
||||
images=list(product.images or []),
|
||||
category_id=product.category_id,
|
||||
created_at=product.created_at,
|
||||
updated_at=product.updated_at,
|
||||
@@ -486,11 +522,20 @@ def create_product(request, data: ProductIn):
|
||||
"""Create a new product. Admin creates approved, others create pending."""
|
||||
user = request.auth
|
||||
is_admin = user and user.role == 'admin' and user.is_active
|
||||
|
||||
|
||||
images = [url.strip() for url in (data.images or []) if url and url.strip()]
|
||||
image = (data.image or "").strip() or (images[0] if images else None)
|
||||
if image and image not in images:
|
||||
images.insert(0, image)
|
||||
if len(images) > MAX_PRODUCT_IMAGES:
|
||||
from ninja.errors import HttpError
|
||||
raise HttpError(400, f"最多上传{MAX_PRODUCT_IMAGES}张图片")
|
||||
|
||||
product = Product.objects.create(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
image=data.image,
|
||||
image=image or None,
|
||||
images=images,
|
||||
category_id=data.category_id,
|
||||
status='approved' if is_admin else 'pending',
|
||||
submitted_by=user,
|
||||
@@ -498,6 +543,31 @@ def create_product(request, data: ProductIn):
|
||||
return product
|
||||
|
||||
|
||||
@router.post("/upload-image/", response=UploadImageOut, auth=JWTAuth())
|
||||
def upload_product_image(request, file: UploadedFile = File(...)):
|
||||
"""Upload product image and return URL."""
|
||||
if not file:
|
||||
raise HttpError(400, "请上传图片文件")
|
||||
|
||||
allowed_types = {"image/jpeg", "image/png", "image/webp"}
|
||||
if (file.content_type or "").lower() not in allowed_types:
|
||||
raise HttpError(400, "仅支持 JPG/PNG/WEBP 图片")
|
||||
|
||||
max_size = 5 * 1024 * 1024
|
||||
if file.size and file.size > max_size:
|
||||
raise HttpError(400, "图片大小不能超过5MB")
|
||||
|
||||
ext = os.path.splitext(file.name or "")[1].lower()
|
||||
if not ext:
|
||||
ext = mimetypes.guess_extension(file.content_type or "") or ".jpg"
|
||||
|
||||
filename = f"products/{uuid.uuid4().hex}{ext}"
|
||||
saved_path = default_storage.save(filename, file)
|
||||
url = f"{settings.MEDIA_URL}{saved_path}".replace("\\", "/")
|
||||
|
||||
return UploadImageOut(url=url)
|
||||
|
||||
|
||||
@router.get("/my/", response=List[MyProductOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def my_products(request, status: Optional[str] = None):
|
||||
|
||||
18
backend/apps/products/migrations/0002_product_images.py
Normal file
18
backend/apps/products/migrations/0002_product_images.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-29 10:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("products", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="product",
|
||||
name="images",
|
||||
field=models.JSONField(blank=True, default=list, verbose_name="图片列表"),
|
||||
),
|
||||
]
|
||||
@@ -82,6 +82,7 @@ class Product(models.Model):
|
||||
name = models.CharField('商品名称', max_length=300)
|
||||
description = models.TextField('描述', blank=True, null=True)
|
||||
image = models.TextField('图片', blank=True, null=True)
|
||||
images = models.JSONField('图片列表', default=list, blank=True)
|
||||
category = models.ForeignKey(
|
||||
Category,
|
||||
on_delete=models.CASCADE,
|
||||
|
||||
@@ -78,6 +78,7 @@ class ProductOut(Schema):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
images: List[str] = []
|
||||
category_id: int
|
||||
status: str = "approved"
|
||||
submitted_by_id: Optional[int] = None
|
||||
@@ -94,11 +95,17 @@ class ProductWithPricesOut(ProductOut):
|
||||
highest_price: Optional[Decimal] = None
|
||||
|
||||
|
||||
class UploadImageOut(Schema):
|
||||
"""Image upload output."""
|
||||
url: str
|
||||
|
||||
|
||||
class ProductIn(Schema):
|
||||
"""Product input schema."""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
images: Optional[List[str]] = None
|
||||
category_id: int
|
||||
|
||||
|
||||
@@ -108,6 +115,7 @@ class MyProductOut(Schema):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
images: List[str] = []
|
||||
category_id: int
|
||||
status: str
|
||||
reject_reason: Optional[str] = None
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
Django Ninja API configuration.
|
||||
"""
|
||||
import logging
|
||||
from django.conf import settings
|
||||
from ninja import NinjaAPI
|
||||
from ninja.errors import HttpError, ValidationError
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
@@ -51,9 +53,13 @@ def on_validation_error(request, exc: ValidationError):
|
||||
|
||||
@api.exception_handler(Exception)
|
||||
def on_unhandled_error(request, exc: Exception):
|
||||
logging.exception("Unhandled API error", exc_info=exc)
|
||||
message = "服务器内部错误"
|
||||
if getattr(settings, "DEBUG", False):
|
||||
message = f"服务器内部错误: {exc}"
|
||||
return api.create_response(
|
||||
request,
|
||||
build_error_payload(status_code=500, message="服务器内部错误"),
|
||||
build_error_payload(status_code=500, message=message),
|
||||
status=500,
|
||||
)
|
||||
|
||||
|
||||
@@ -145,6 +145,8 @@ USE_TZ = True
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
|
||||
# Default primary key field type
|
||||
|
||||
@@ -3,6 +3,8 @@ URL configuration for ai_web project.
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from .api import api
|
||||
from apps.bounties.payments import handle_webhook
|
||||
@@ -12,3 +14,6 @@ urlpatterns = [
|
||||
path('api/', api.urls),
|
||||
path('webhooks/stripe/', csrf_exempt(handle_webhook), name='stripe-webhook'),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
Reference in New Issue
Block a user