This commit is contained in:
27942
2026-01-29 13:18:59 +08:00
parent 2471ed8a05
commit 407471c1ff
25 changed files with 1531 additions and 77 deletions

View File

@@ -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

View File

@@ -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):

View 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="图片列表"),
),
]

View File

@@ -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,

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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

View File

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