haha
This commit is contained in:
@@ -10,7 +10,7 @@ from ninja_jwt.authentication import JWTAuth
|
||||
from ninja.pagination import paginate, PageNumberPagination
|
||||
|
||||
from apps.users.models import User
|
||||
from apps.products.models import Product, Website, Category
|
||||
from apps.products.models import Product, Website, Category, ComparisonTag, ComparisonTagItem, ProductPrice, ProductPriceHistory
|
||||
from apps.bounties.models import Bounty, BountyDispute, PaymentEvent
|
||||
|
||||
router = Router()
|
||||
@@ -40,6 +40,10 @@ class SimpleOut(Schema):
|
||||
name: str
|
||||
|
||||
|
||||
class MessageOut(Schema):
|
||||
message: str
|
||||
|
||||
|
||||
class BountyAdminOut(Schema):
|
||||
id: int
|
||||
title: str
|
||||
@@ -193,6 +197,77 @@ class ProductImagesIn(Schema):
|
||||
image: Optional[str] = None
|
||||
|
||||
|
||||
class ComparisonTagAdminOut(Schema):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
cover_image: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
product_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ComparisonTagIn(Schema):
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
cover_image: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class ComparisonTagUpdate(Schema):
|
||||
name: Optional[str] = None
|
||||
slug: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
cover_image: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class ComparisonTagItemOut(Schema):
|
||||
id: int
|
||||
tag_id: int
|
||||
product_id: int
|
||||
product_name: str
|
||||
product_image: Optional[str] = None
|
||||
product_status: str
|
||||
sort_order: int
|
||||
is_pinned: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ComparisonTagItemIn(Schema):
|
||||
product_ids: List[int]
|
||||
sort_order: int = 0
|
||||
is_pinned: bool = False
|
||||
|
||||
|
||||
class ComparisonTagItemUpdate(Schema):
|
||||
sort_order: Optional[int] = None
|
||||
is_pinned: Optional[bool] = None
|
||||
|
||||
|
||||
class BackfillHistoryIn(Schema):
|
||||
product_ids: Optional[List[int]] = None
|
||||
|
||||
|
||||
class BackfillHistoryOut(Schema):
|
||||
created: int
|
||||
skipped: int
|
||||
|
||||
|
||||
class RecordHistoryIn(Schema):
|
||||
product_ids: Optional[List[int]] = None
|
||||
force: bool = False
|
||||
|
||||
|
||||
@router.get("/products/pending/", response=List[ProductAdminOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def list_pending_products(request):
|
||||
@@ -262,3 +337,235 @@ def update_product_images(request, product_id: int, data: ProductImagesIn):
|
||||
product.images = images
|
||||
product.save(update_fields=["image", "images", "updated_at"])
|
||||
return product
|
||||
|
||||
|
||||
# ==================== Comparison Tag Management ====================
|
||||
|
||||
@router.get("/compare-tags/", response=List[ComparisonTagAdminOut], auth=JWTAuth())
|
||||
def list_compare_tags(request):
|
||||
require_admin(request.auth)
|
||||
tags = ComparisonTag.objects.all().order_by("sort_order", "id")
|
||||
return [
|
||||
ComparisonTagAdminOut(
|
||||
id=tag.id,
|
||||
name=tag.name,
|
||||
slug=tag.slug,
|
||||
description=tag.description,
|
||||
cover_image=tag.cover_image,
|
||||
icon=tag.icon,
|
||||
sort_order=tag.sort_order,
|
||||
is_active=tag.is_active,
|
||||
product_count=tag.items.count(),
|
||||
created_at=tag.created_at,
|
||||
updated_at=tag.updated_at,
|
||||
)
|
||||
for tag in tags
|
||||
]
|
||||
|
||||
|
||||
@router.post("/compare-tags/", response=ComparisonTagAdminOut, auth=JWTAuth())
|
||||
def create_compare_tag(request, data: ComparisonTagIn):
|
||||
require_admin(request.auth)
|
||||
if ComparisonTag.objects.filter(slug=data.slug).exists():
|
||||
raise HttpError(400, "标签标识已存在")
|
||||
tag = ComparisonTag.objects.create(**data.dict())
|
||||
return ComparisonTagAdminOut(
|
||||
id=tag.id,
|
||||
name=tag.name,
|
||||
slug=tag.slug,
|
||||
description=tag.description,
|
||||
cover_image=tag.cover_image,
|
||||
icon=tag.icon,
|
||||
sort_order=tag.sort_order,
|
||||
is_active=tag.is_active,
|
||||
product_count=0,
|
||||
created_at=tag.created_at,
|
||||
updated_at=tag.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/compare-tags/{tag_id}", response=ComparisonTagAdminOut, auth=JWTAuth())
|
||||
def update_compare_tag(request, tag_id: int, data: ComparisonTagUpdate):
|
||||
require_admin(request.auth)
|
||||
try:
|
||||
tag = ComparisonTag.objects.get(id=tag_id)
|
||||
except ComparisonTag.DoesNotExist:
|
||||
raise HttpError(404, "标签不存在")
|
||||
update_data = data.dict(exclude_unset=True)
|
||||
if "slug" in update_data:
|
||||
if ComparisonTag.objects.filter(slug=update_data["slug"]).exclude(id=tag_id).exists():
|
||||
raise HttpError(400, "标签标识已存在")
|
||||
for key, value in update_data.items():
|
||||
setattr(tag, key, value)
|
||||
tag.save()
|
||||
return ComparisonTagAdminOut(
|
||||
id=tag.id,
|
||||
name=tag.name,
|
||||
slug=tag.slug,
|
||||
description=tag.description,
|
||||
cover_image=tag.cover_image,
|
||||
icon=tag.icon,
|
||||
sort_order=tag.sort_order,
|
||||
is_active=tag.is_active,
|
||||
product_count=tag.items.count(),
|
||||
created_at=tag.created_at,
|
||||
updated_at=tag.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/compare-tags/{tag_id}", response=MessageOut, auth=JWTAuth())
|
||||
def delete_compare_tag(request, tag_id: int):
|
||||
require_admin(request.auth)
|
||||
deleted, _ = ComparisonTag.objects.filter(id=tag_id).delete()
|
||||
if not deleted:
|
||||
raise HttpError(404, "标签不存在")
|
||||
return MessageOut(message="标签已删除")
|
||||
|
||||
|
||||
@router.get("/compare-tags/{tag_id}/items/", response=List[ComparisonTagItemOut], auth=JWTAuth())
|
||||
def list_compare_tag_items(request, tag_id: int):
|
||||
require_admin(request.auth)
|
||||
items = (
|
||||
ComparisonTagItem.objects.select_related("product")
|
||||
.filter(tag_id=tag_id)
|
||||
.order_by("-is_pinned", "sort_order", "id")
|
||||
)
|
||||
return [
|
||||
ComparisonTagItemOut(
|
||||
id=item.id,
|
||||
tag_id=item.tag_id,
|
||||
product_id=item.product_id,
|
||||
product_name=item.product.name,
|
||||
product_image=item.product.image,
|
||||
product_status=item.product.status,
|
||||
sort_order=item.sort_order,
|
||||
is_pinned=item.is_pinned,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
|
||||
|
||||
@router.post("/compare-tags/{tag_id}/items/", response=List[ComparisonTagItemOut], auth=JWTAuth())
|
||||
def add_compare_tag_items(request, tag_id: int, data: ComparisonTagItemIn):
|
||||
require_admin(request.auth)
|
||||
try:
|
||||
tag = ComparisonTag.objects.get(id=tag_id)
|
||||
except ComparisonTag.DoesNotExist:
|
||||
raise HttpError(404, "标签不存在")
|
||||
product_ids = list(dict.fromkeys(data.product_ids or []))
|
||||
if not product_ids:
|
||||
raise HttpError(400, "请选择商品")
|
||||
existing = set(
|
||||
ComparisonTagItem.objects.filter(tag=tag, product_id__in=product_ids).values_list("product_id", flat=True)
|
||||
)
|
||||
items = []
|
||||
for product_id in product_ids:
|
||||
if product_id in existing:
|
||||
continue
|
||||
try:
|
||||
product = Product.objects.get(id=product_id)
|
||||
except Product.DoesNotExist:
|
||||
continue
|
||||
items.append(
|
||||
ComparisonTagItem(
|
||||
tag=tag,
|
||||
product=product,
|
||||
sort_order=data.sort_order,
|
||||
is_pinned=data.is_pinned,
|
||||
)
|
||||
)
|
||||
if items:
|
||||
ComparisonTagItem.objects.bulk_create(items)
|
||||
|
||||
return list_compare_tag_items(request, tag_id)
|
||||
|
||||
|
||||
@router.patch("/compare-tags/{tag_id}/items/{item_id}", response=ComparisonTagItemOut, auth=JWTAuth())
|
||||
def update_compare_tag_item(request, tag_id: int, item_id: int, data: ComparisonTagItemUpdate):
|
||||
require_admin(request.auth)
|
||||
try:
|
||||
item = ComparisonTagItem.objects.select_related("product").get(id=item_id, tag_id=tag_id)
|
||||
except ComparisonTagItem.DoesNotExist:
|
||||
raise HttpError(404, "标签商品不存在")
|
||||
update_data = data.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(item, key, value)
|
||||
item.save()
|
||||
return ComparisonTagItemOut(
|
||||
id=item.id,
|
||||
tag_id=item.tag_id,
|
||||
product_id=item.product_id,
|
||||
product_name=item.product.name,
|
||||
product_image=item.product.image,
|
||||
product_status=item.product.status,
|
||||
sort_order=item.sort_order,
|
||||
is_pinned=item.is_pinned,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/compare-tags/{tag_id}/items/{item_id}", response=MessageOut, auth=JWTAuth())
|
||||
def delete_compare_tag_item(request, tag_id: int, item_id: int):
|
||||
require_admin(request.auth)
|
||||
deleted, _ = ComparisonTagItem.objects.filter(id=item_id, tag_id=tag_id).delete()
|
||||
if not deleted:
|
||||
raise HttpError(404, "标签商品不存在")
|
||||
return MessageOut(message="标签商品已移除")
|
||||
|
||||
|
||||
def _record_price_history(product_ids: Optional[List[int]] = None, force: bool = False):
|
||||
queryset = ProductPrice.objects.select_related("product", "website").all()
|
||||
if product_ids:
|
||||
queryset = queryset.filter(product_id__in=product_ids)
|
||||
created = 0
|
||||
skipped = 0
|
||||
for price in queryset:
|
||||
latest = ProductPriceHistory.objects.filter(
|
||||
product_id=price.product_id,
|
||||
website_id=price.website_id,
|
||||
).order_by("-recorded_at").first()
|
||||
if force or (not latest or latest.price != price.price):
|
||||
ProductPriceHistory.objects.create(
|
||||
product_id=price.product_id,
|
||||
website_id=price.website_id,
|
||||
price=price.price,
|
||||
)
|
||||
created += 1
|
||||
else:
|
||||
skipped += 1
|
||||
return created, skipped
|
||||
|
||||
|
||||
@router.post("/price-history/backfill/", response=BackfillHistoryOut, auth=JWTAuth())
|
||||
def backfill_price_history(request, data: BackfillHistoryIn):
|
||||
require_admin(request.auth)
|
||||
created, skipped = _record_price_history(data.product_ids, force=False)
|
||||
return BackfillHistoryOut(created=created, skipped=skipped)
|
||||
|
||||
|
||||
@router.post("/compare-tags/{tag_id}/backfill-history/", response=BackfillHistoryOut, auth=JWTAuth())
|
||||
def backfill_tag_price_history(request, tag_id: int):
|
||||
require_admin(request.auth)
|
||||
product_ids = list(
|
||||
ComparisonTagItem.objects.filter(tag_id=tag_id).values_list("product_id", flat=True)
|
||||
)
|
||||
created, skipped = _record_price_history(product_ids, force=False)
|
||||
return BackfillHistoryOut(created=created, skipped=skipped)
|
||||
|
||||
|
||||
@router.post("/price-history/record/", response=BackfillHistoryOut, auth=JWTAuth())
|
||||
def record_price_history(request, data: RecordHistoryIn):
|
||||
require_admin(request.auth)
|
||||
created, skipped = _record_price_history(data.product_ids, force=data.force)
|
||||
return BackfillHistoryOut(created=created, skipped=skipped)
|
||||
|
||||
|
||||
@router.post("/compare-tags/{tag_id}/record-history/", response=BackfillHistoryOut, auth=JWTAuth())
|
||||
def record_tag_price_history(request, tag_id: int, data: RecordHistoryIn):
|
||||
require_admin(request.auth)
|
||||
product_ids = list(
|
||||
ComparisonTagItem.objects.filter(tag_id=tag_id).values_list("product_id", flat=True)
|
||||
)
|
||||
created, skipped = _record_price_history(product_ids, force=data.force)
|
||||
return BackfillHistoryOut(created=created, skipped=skipped)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from .models import Category, Website, Product, ProductPrice
|
||||
from .models import Category, Website, Product, ProductPrice, ProductPriceHistory, ComparisonTag, ComparisonTagItem
|
||||
|
||||
|
||||
@admin.register(Category)
|
||||
@@ -33,3 +33,36 @@ class ProductPriceAdmin(admin.ModelAdmin):
|
||||
list_filter = ['website', 'in_stock', 'currency']
|
||||
search_fields = ['product__name', 'website__name']
|
||||
ordering = ['-updated_at']
|
||||
|
||||
|
||||
@admin.register(ProductPriceHistory)
|
||||
class ProductPriceHistoryAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'product', 'website', 'price', 'recorded_at']
|
||||
list_filter = ['website']
|
||||
search_fields = ['product__name', 'website__name']
|
||||
ordering = ['-recorded_at']
|
||||
|
||||
|
||||
class ComparisonTagItemInline(admin.TabularInline):
|
||||
model = ComparisonTagItem
|
||||
extra = 1
|
||||
autocomplete_fields = ["product"]
|
||||
ordering = ["-is_pinned", "sort_order", "id"]
|
||||
|
||||
|
||||
@admin.register(ComparisonTag)
|
||||
class ComparisonTagAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'name', 'slug', 'is_active', 'sort_order', 'created_at']
|
||||
list_filter = ['is_active']
|
||||
search_fields = ['name', 'slug']
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
ordering = ['sort_order', 'id']
|
||||
inlines = [ComparisonTagItemInline]
|
||||
|
||||
|
||||
@admin.register(ComparisonTagItem)
|
||||
class ComparisonTagItemAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'tag', 'product', 'is_pinned', 'sort_order', 'created_at']
|
||||
list_filter = ['tag', 'is_pinned']
|
||||
search_fields = ['product__name', 'tag__name']
|
||||
ordering = ['-created_at']
|
||||
|
||||
@@ -22,15 +22,19 @@ 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
|
||||
from .models import Category, Website, Product, ProductPrice, ProductPriceHistory, ComparisonTag, ComparisonTagItem
|
||||
from .schemas import (
|
||||
CategoryOut, CategoryIn,
|
||||
WebsiteOut, WebsiteIn, WebsiteFilter,
|
||||
ProductOut, ProductIn, ProductWithPricesOut, ProductPriceOut, ProductPriceIn,
|
||||
ProductFilter,
|
||||
ProductSearchFilter,
|
||||
ProductPriceHistoryOut,
|
||||
PriceHistoryStatsOut,
|
||||
EnhancedProductPriceOut,
|
||||
ComparisonTagOut,
|
||||
ComparisonTagDetailOut,
|
||||
ComparisonTagItemOut,
|
||||
ImportResultOut,
|
||||
MyProductOut,
|
||||
UploadImageOut,
|
||||
@@ -40,21 +44,29 @@ from apps.favorites.models import Favorite
|
||||
router = Router()
|
||||
category_router = Router()
|
||||
website_router = Router()
|
||||
compare_tag_router = Router()
|
||||
|
||||
MAX_PRODUCT_IMAGES = 6
|
||||
|
||||
|
||||
def record_product_price_history(price_record: ProductPrice):
|
||||
"""Record price history for a product price record."""
|
||||
ProductPriceHistory.objects.create(
|
||||
product_id=price_record.product_id,
|
||||
website_id=price_record.website_id,
|
||||
price=price_record.price,
|
||||
)
|
||||
|
||||
|
||||
# ==================== Category Routes ====================
|
||||
|
||||
@category_router.get("/", response=List[CategoryOut])
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def list_categories(request):
|
||||
"""Get all categories."""
|
||||
return Category.objects.all()
|
||||
|
||||
|
||||
@category_router.get("/{slug}", response=CategoryOut)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def get_category_by_slug(request, slug: str):
|
||||
"""Get category by slug."""
|
||||
return get_object_or_404(Category, slug=slug)
|
||||
@@ -115,7 +127,6 @@ def create_category(request, data: CategoryIn):
|
||||
|
||||
@website_router.get("/", response=List[WebsiteOut])
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def list_websites(request, filters: WebsiteFilter = Query(...)):
|
||||
"""Get all websites with optional filters."""
|
||||
queryset = Website.objects.all()
|
||||
@@ -129,7 +140,6 @@ def list_websites(request, filters: WebsiteFilter = Query(...)):
|
||||
|
||||
|
||||
@website_router.get("/{website_id}", response=WebsiteOut)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def get_website(request, website_id: int):
|
||||
"""Get website by ID."""
|
||||
return get_object_or_404(Website, id=website_id)
|
||||
@@ -142,6 +152,199 @@ def create_website(request, data: WebsiteIn):
|
||||
return website
|
||||
|
||||
|
||||
# ==================== Comparison Tag Routes ====================
|
||||
|
||||
@compare_tag_router.get("/", response=List[ComparisonTagOut])
|
||||
def list_compare_tags(request):
|
||||
"""List active comparison tags."""
|
||||
tags = (
|
||||
ComparisonTag.objects.filter(is_active=True)
|
||||
.annotate(product_count=Count("items", filter=Q(items__product__status="approved"), distinct=True))
|
||||
.order_by("sort_order", "id")
|
||||
)
|
||||
return [
|
||||
ComparisonTagOut(
|
||||
id=tag.id,
|
||||
name=tag.name,
|
||||
slug=tag.slug,
|
||||
description=tag.description,
|
||||
cover_image=tag.cover_image,
|
||||
icon=tag.icon,
|
||||
sort_order=tag.sort_order,
|
||||
is_active=tag.is_active,
|
||||
product_count=tag.product_count or 0,
|
||||
created_at=tag.created_at,
|
||||
updated_at=tag.updated_at,
|
||||
)
|
||||
for tag in tags
|
||||
]
|
||||
|
||||
|
||||
@compare_tag_router.get("/{slug}", response=ComparisonTagDetailOut)
|
||||
def get_compare_tag_detail(request, slug: str, only_discounted: bool = False, min_discount_percent: Optional[int] = None, only_historical_lowest: bool = False):
|
||||
"""Get comparison tag detail with product prices and enhanced stats."""
|
||||
tag = get_object_or_404(ComparisonTag, slug=slug, is_active=True)
|
||||
prices_prefetch = Prefetch(
|
||||
"product__prices",
|
||||
queryset=ProductPrice.objects.select_related("website"),
|
||||
)
|
||||
items = (
|
||||
ComparisonTagItem.objects.filter(tag=tag, product__status="approved")
|
||||
.select_related("product", "product__category")
|
||||
.prefetch_related(prices_prefetch)
|
||||
.order_by("-is_pinned", "sort_order", "id")
|
||||
)
|
||||
|
||||
# 获取所有商品ID,批量查询历史最低价
|
||||
product_ids = [item.product_id for item in items]
|
||||
historical_lowest_map = {}
|
||||
if product_ids:
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models import Subquery, OuterRef
|
||||
# 查询每个商品的历史最低价
|
||||
for pid in product_ids:
|
||||
history = ProductPriceHistory.objects.filter(product_id=pid).order_by("price").first()
|
||||
if history:
|
||||
historical_lowest_map[pid] = {
|
||||
"price": history.price,
|
||||
"date": history.recorded_at,
|
||||
}
|
||||
|
||||
item_out: List[ComparisonTagItemOut] = []
|
||||
for item in items:
|
||||
product = item.product
|
||||
prices = list(product.prices.all())
|
||||
|
||||
# 获取历史最低价信息
|
||||
hist_info = historical_lowest_map.get(product.id, {})
|
||||
historical_lowest = hist_info.get("price")
|
||||
historical_lowest_date = hist_info.get("date")
|
||||
|
||||
# 当前最低价
|
||||
current_lowest = min((pp.price for pp in prices), default=None)
|
||||
current_highest = max((pp.price for pp in prices), default=None)
|
||||
|
||||
# 判断是否处于历史最低
|
||||
is_at_historical_lowest = False
|
||||
if historical_lowest and current_lowest:
|
||||
is_at_historical_lowest = current_lowest <= historical_lowest
|
||||
|
||||
# 计算距最高价降幅百分比
|
||||
discount_from_highest_percent = None
|
||||
if current_lowest and current_highest and current_highest > 0:
|
||||
discount_from_highest_percent = round((1 - current_lowest / current_highest) * 100, 1)
|
||||
|
||||
# 推荐逻辑
|
||||
is_recommended = False
|
||||
recommendation_reason = None
|
||||
if is_at_historical_lowest:
|
||||
is_recommended = True
|
||||
recommendation_reason = "历史最低价"
|
||||
elif discount_from_highest_percent and discount_from_highest_percent >= 30:
|
||||
is_recommended = True
|
||||
recommendation_reason = f"降幅{discount_from_highest_percent}%"
|
||||
|
||||
# 筛选:只看降价商品
|
||||
if only_discounted:
|
||||
has_discount = any(pp.original_price and pp.original_price > pp.price for pp in prices)
|
||||
if not has_discount:
|
||||
continue
|
||||
|
||||
# 筛选:最小降价幅度
|
||||
if min_discount_percent:
|
||||
max_discount = 0
|
||||
for pp in prices:
|
||||
if pp.original_price and pp.original_price > 0:
|
||||
discount = (1 - pp.price / pp.original_price) * 100
|
||||
max_discount = max(max_discount, discount)
|
||||
if max_discount < min_discount_percent:
|
||||
continue
|
||||
|
||||
# 筛选:只看历史最低
|
||||
if only_historical_lowest and not is_at_historical_lowest:
|
||||
continue
|
||||
|
||||
price_outs: List[EnhancedProductPriceOut] = []
|
||||
for pp in prices:
|
||||
website_name = None
|
||||
website_logo = None
|
||||
if pp.website_id:
|
||||
website_name = pp.website.name
|
||||
website_logo = pp.website.logo
|
||||
|
||||
# 计算单个价格的降价信息
|
||||
discount_percent = None
|
||||
discount_amount = None
|
||||
if pp.original_price and pp.original_price > pp.price:
|
||||
discount_amount = pp.original_price - pp.price
|
||||
discount_percent = round((discount_amount / pp.original_price) * 100, 1)
|
||||
|
||||
# 判断是否处于该商品的历史最低
|
||||
price_is_at_lowest = False
|
||||
if historical_lowest:
|
||||
price_is_at_lowest = pp.price <= historical_lowest
|
||||
|
||||
price_outs.append(EnhancedProductPriceOut(
|
||||
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,
|
||||
historical_lowest=historical_lowest,
|
||||
is_at_historical_lowest=price_is_at_lowest,
|
||||
discount_percent=discount_percent,
|
||||
discount_amount=discount_amount,
|
||||
))
|
||||
|
||||
item_out.append(ComparisonTagItemOut(
|
||||
product=ProductOut(
|
||||
id=product.id,
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
image=product.image,
|
||||
images=list(product.images or []),
|
||||
category_id=product.category_id,
|
||||
status=product.status,
|
||||
submitted_by_id=product.submitted_by_id,
|
||||
reject_reason=product.reject_reason,
|
||||
reviewed_at=product.reviewed_at,
|
||||
created_at=product.created_at,
|
||||
updated_at=product.updated_at,
|
||||
),
|
||||
prices=price_outs,
|
||||
lowest_price=current_lowest,
|
||||
highest_price=current_highest,
|
||||
platform_count=len(prices),
|
||||
historical_lowest=historical_lowest,
|
||||
historical_lowest_date=historical_lowest_date,
|
||||
is_at_historical_lowest=is_at_historical_lowest,
|
||||
discount_from_highest_percent=discount_from_highest_percent,
|
||||
is_recommended=is_recommended,
|
||||
recommendation_reason=recommendation_reason,
|
||||
))
|
||||
|
||||
tag_out = ComparisonTagOut(
|
||||
id=tag.id,
|
||||
name=tag.name,
|
||||
slug=tag.slug,
|
||||
description=tag.description,
|
||||
cover_image=tag.cover_image,
|
||||
icon=tag.icon,
|
||||
sort_order=tag.sort_order,
|
||||
is_active=tag.is_active,
|
||||
product_count=len(item_out),
|
||||
created_at=tag.created_at,
|
||||
updated_at=tag.updated_at,
|
||||
)
|
||||
return ComparisonTagDetailOut(tag=tag_out, items=item_out)
|
||||
|
||||
|
||||
# ==================== Product Routes ====================
|
||||
|
||||
@router.post("/import/", response=ImportResultOut, auth=JWTAuth())
|
||||
@@ -265,6 +468,10 @@ def import_products_csv(request, file: UploadedFile = File(...)):
|
||||
currency = (row.get("currency") or "CNY").strip() or "CNY"
|
||||
in_stock = parse_bool(row.get("in_stock"), True)
|
||||
|
||||
existing_price = ProductPrice.objects.filter(
|
||||
product=product,
|
||||
website=website,
|
||||
).first()
|
||||
price_record, created = ProductPrice.objects.update_or_create(
|
||||
product=product,
|
||||
website=website,
|
||||
@@ -280,6 +487,8 @@ def import_products_csv(request, file: UploadedFile = File(...)):
|
||||
result.created_prices += 1
|
||||
else:
|
||||
result.updated_prices += 1
|
||||
if created or (existing_price and existing_price.price != price_record.price):
|
||||
record_product_price_history(price_record)
|
||||
except Exception:
|
||||
result.errors.append(f"第{idx}行处理失败")
|
||||
continue
|
||||
@@ -322,7 +531,6 @@ def recommend_products(request, limit: int = 12):
|
||||
|
||||
@router.get("/", response=List[ProductOut])
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def list_products(request, filters: ProductFilter = Query(...)):
|
||||
"""Get all approved products with optional filters."""
|
||||
# 只显示已审核通过的商品
|
||||
@@ -362,7 +570,6 @@ def list_products(request, filters: ProductFilter = Query(...)):
|
||||
|
||||
|
||||
@router.get("/{product_id}", response=ProductOut)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def get_product(request, product_id: int):
|
||||
"""Get product by ID."""
|
||||
# 只返回已审核通过的商品
|
||||
@@ -430,7 +637,6 @@ def get_product_with_prices(request, product_id: int):
|
||||
|
||||
@router.get("/search/", response=List[ProductWithPricesOut])
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def search_products(request, q: str, filters: ProductSearchFilter = Query(...)):
|
||||
"""Search approved products by name, description, or by user_id (submitter)."""
|
||||
from apps.users.models import User
|
||||
@@ -599,6 +805,7 @@ def add_product_price(request, data: ProductPriceIn):
|
||||
from ninja.errors import HttpError
|
||||
raise HttpError(403, "只能为自己提交的商品添加价格")
|
||||
price = ProductPrice.objects.create(**data.dict())
|
||||
record_product_price_history(price)
|
||||
website = price.website
|
||||
|
||||
return ProductPriceOut(
|
||||
@@ -614,3 +821,120 @@ def add_product_price(request, data: ProductPriceIn):
|
||||
in_stock=price.in_stock,
|
||||
last_checked=price.last_checked,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{product_id}/price-history/", response=List[ProductPriceHistoryOut])
|
||||
def get_product_price_history(request, product_id: int, website_id: Optional[int] = None, limit: int = 60, days: Optional[int] = None):
|
||||
"""Get product price history."""
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
queryset = ProductPriceHistory.objects.filter(product_id=product_id)
|
||||
if website_id:
|
||||
queryset = queryset.filter(website_id=website_id)
|
||||
if days:
|
||||
cutoff = timezone.now() - timedelta(days=days)
|
||||
queryset = queryset.filter(recorded_at__gte=cutoff)
|
||||
|
||||
limit = max(1, min(limit, 500))
|
||||
histories = list(queryset.order_by("-recorded_at")[:limit])
|
||||
histories.reverse()
|
||||
return [
|
||||
ProductPriceHistoryOut(
|
||||
id=h.id,
|
||||
product_id=h.product_id,
|
||||
website_id=h.website_id,
|
||||
price=h.price,
|
||||
recorded_at=h.recorded_at,
|
||||
)
|
||||
for h in histories
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{product_id}/price-stats/", response=PriceHistoryStatsOut)
|
||||
def get_product_price_stats(request, product_id: int, days: Optional[int] = None):
|
||||
"""Get product price statistics including historical lowest/highest."""
|
||||
from django.utils import timezone
|
||||
from django.db.models import Avg
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
queryset = ProductPriceHistory.objects.filter(product_id=product_id)
|
||||
if days:
|
||||
cutoff = timezone.now() - timedelta(days=days)
|
||||
queryset = queryset.filter(recorded_at__gte=cutoff)
|
||||
|
||||
# 历史最低
|
||||
lowest_record = queryset.order_by("price").first()
|
||||
historical_lowest = lowest_record.price if lowest_record else None
|
||||
historical_lowest_date = lowest_record.recorded_at if lowest_record else None
|
||||
|
||||
# 历史最高
|
||||
highest_record = queryset.order_by("-price").first()
|
||||
historical_highest = highest_record.price if highest_record else None
|
||||
historical_highest_date = highest_record.recorded_at if highest_record else None
|
||||
|
||||
# 平均价
|
||||
avg_result = queryset.aggregate(avg_price=Avg("price"))
|
||||
average_price = avg_result["avg_price"]
|
||||
if average_price:
|
||||
average_price = round(Decimal(str(average_price)), 2)
|
||||
|
||||
# 当前最低价
|
||||
current_prices = ProductPrice.objects.filter(product_id=product_id)
|
||||
current_lowest_record = current_prices.order_by("price").first()
|
||||
current_lowest = current_lowest_record.price if current_lowest_record else None
|
||||
|
||||
# 是否处于历史最低
|
||||
is_historical_lowest = False
|
||||
if current_lowest and historical_lowest:
|
||||
is_historical_lowest = current_lowest <= historical_lowest
|
||||
|
||||
# 距最高价降幅
|
||||
discount_from_highest = None
|
||||
if current_lowest and historical_highest and historical_highest > 0:
|
||||
discount_from_highest = round((1 - current_lowest / historical_highest) * 100, 1)
|
||||
|
||||
# 距历史最低差额
|
||||
distance_to_lowest = None
|
||||
if current_lowest and historical_lowest:
|
||||
distance_to_lowest = current_lowest - historical_lowest
|
||||
|
||||
# 价格趋势(最近7条记录)
|
||||
recent = list(queryset.order_by("-recorded_at")[:7])
|
||||
price_trend = "stable"
|
||||
if len(recent) >= 2:
|
||||
first_price = recent[-1].price
|
||||
last_price = recent[0].price
|
||||
if last_price < first_price * Decimal("0.95"):
|
||||
price_trend = "down"
|
||||
elif last_price > first_price * Decimal("1.05"):
|
||||
price_trend = "up"
|
||||
|
||||
# 历史数据(最近60条)
|
||||
histories = list(queryset.order_by("-recorded_at")[:60])
|
||||
histories.reverse()
|
||||
|
||||
return PriceHistoryStatsOut(
|
||||
product_id=product_id,
|
||||
historical_lowest=historical_lowest,
|
||||
historical_lowest_date=historical_lowest_date,
|
||||
historical_highest=historical_highest,
|
||||
historical_highest_date=historical_highest_date,
|
||||
average_price=average_price,
|
||||
current_lowest=current_lowest,
|
||||
is_historical_lowest=is_historical_lowest,
|
||||
discount_from_highest=discount_from_highest,
|
||||
distance_to_lowest=distance_to_lowest,
|
||||
price_trend=price_trend,
|
||||
history=[
|
||||
ProductPriceHistoryOut(
|
||||
id=h.id,
|
||||
product_id=h.product_id,
|
||||
website_id=h.website_id,
|
||||
price=h.price,
|
||||
recorded_at=h.recorded_at,
|
||||
)
|
||||
for h in histories
|
||||
],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Record current product prices into history.
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from apps.products.models import ProductPrice, ProductPriceHistory
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Record current product prices into history."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Always create a history record even if price unchanged",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
force = options.get("force", False)
|
||||
created = 0
|
||||
skipped = 0
|
||||
queryset = ProductPrice.objects.select_related("product", "website").all()
|
||||
for price in queryset:
|
||||
latest = ProductPriceHistory.objects.filter(
|
||||
product_id=price.product_id,
|
||||
website_id=price.website_id,
|
||||
).order_by("-recorded_at").first()
|
||||
if force or (not latest or latest.price != price.price):
|
||||
ProductPriceHistory.objects.create(
|
||||
product_id=price.product_id,
|
||||
website_id=price.website_id,
|
||||
price=price.price,
|
||||
)
|
||||
created += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"History recorded. created={created}, skipped={skipped}"
|
||||
))
|
||||
|
||||
57
backend/apps/products/migrations/0006_comparison_tags.py
Normal file
57
backend/apps/products/migrations/0006_comparison_tags.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("products", "0005_merge_20260130_1626"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ComparisonTag",
|
||||
fields=[
|
||||
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("name", models.CharField(max_length=120, verbose_name="标签名称")),
|
||||
("slug", models.CharField(max_length=120, unique=True, verbose_name="Slug")),
|
||||
("description", models.TextField(blank=True, null=True, verbose_name="描述")),
|
||||
("cover_image", models.TextField(blank=True, null=True, verbose_name="封面图")),
|
||||
("icon", models.CharField(blank=True, max_length=120, null=True, verbose_name="图标")),
|
||||
("sort_order", models.IntegerField(default=0, verbose_name="排序")),
|
||||
("is_active", models.BooleanField(default=True, verbose_name="是否启用")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
|
||||
("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "比价标签",
|
||||
"verbose_name_plural": "比价标签",
|
||||
"db_table": "comparison_tags",
|
||||
"ordering": ["sort_order", "id"],
|
||||
"indexes": [
|
||||
models.Index(fields=["is_active", "sort_order"], name="comparison_is_4b7062_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ComparisonTagItem",
|
||||
fields=[
|
||||
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("sort_order", models.IntegerField(default=0, verbose_name="排序")),
|
||||
("is_pinned", models.BooleanField(default=False, verbose_name="置顶")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
|
||||
("product", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="comparison_items", to="products.product", verbose_name="商品")),
|
||||
("tag", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="items", to="products.comparisontag", verbose_name="比价标签")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "比价标签商品",
|
||||
"verbose_name_plural": "比价标签商品",
|
||||
"db_table": "comparison_tag_items",
|
||||
"ordering": ["-is_pinned", "sort_order", "id"],
|
||||
"unique_together": {("tag", "product")},
|
||||
"indexes": [
|
||||
models.Index(fields=["tag", "sort_order"], name="comparison_tag_183b4d_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("products", "0006_comparison_tags"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ProductPriceHistory",
|
||||
fields=[
|
||||
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("price", models.DecimalField(decimal_places=2, max_digits=10, verbose_name="价格")),
|
||||
("recorded_at", models.DateTimeField(auto_now_add=True, verbose_name="记录时间")),
|
||||
("product", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="price_history", to="products.product", verbose_name="商品")),
|
||||
("website", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="price_history", to="products.website", verbose_name="网站")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "商品价格历史",
|
||||
"verbose_name_plural": "商品价格历史",
|
||||
"db_table": "product_price_history",
|
||||
"ordering": ["-recorded_at"],
|
||||
"indexes": [
|
||||
models.Index(fields=["product", "website", "recorded_at"], name="product_pri_6b8559_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 4.2.27 on 2026-02-03 09:33
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0007_product_price_history'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name='comparisontag',
|
||||
new_name='comparison__is_acti_f2c47e_idx',
|
||||
old_name='comparison_is_4b7062_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='comparisontagitem',
|
||||
new_name='comparison__tag_id_ea17fc_idx',
|
||||
old_name='comparison_tag_183b4d_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='productpricehistory',
|
||||
new_name='product_pri_product_8d9f8a_idx',
|
||||
old_name='product_pri_6b8559_idx',
|
||||
),
|
||||
]
|
||||
@@ -122,6 +122,67 @@ class Product(models.Model):
|
||||
return self.name
|
||||
|
||||
|
||||
class ComparisonTag(models.Model):
|
||||
"""Admin-curated comparison tags for products."""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
name = models.CharField('标签名称', max_length=120)
|
||||
slug = models.CharField('Slug', max_length=120, unique=True)
|
||||
description = models.TextField('描述', blank=True, null=True)
|
||||
cover_image = models.TextField('封面图', blank=True, null=True)
|
||||
icon = models.CharField('图标', max_length=120, blank=True, null=True)
|
||||
sort_order = models.IntegerField('排序', default=0)
|
||||
is_active = models.BooleanField('是否启用', default=True)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'comparison_tags'
|
||||
verbose_name = '比价标签'
|
||||
verbose_name_plural = '比价标签'
|
||||
ordering = ['sort_order', 'id']
|
||||
indexes = [
|
||||
models.Index(fields=["is_active", "sort_order"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ComparisonTagItem(models.Model):
|
||||
"""Products included in a comparison tag."""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
tag = models.ForeignKey(
|
||||
ComparisonTag,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items',
|
||||
verbose_name='比价标签',
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
Product,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='comparison_items',
|
||||
verbose_name='商品',
|
||||
)
|
||||
sort_order = models.IntegerField('排序', default=0)
|
||||
is_pinned = models.BooleanField('置顶', default=False)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'comparison_tag_items'
|
||||
verbose_name = '比价标签商品'
|
||||
verbose_name_plural = '比价标签商品'
|
||||
ordering = ['-is_pinned', 'sort_order', 'id']
|
||||
unique_together = ['tag', 'product']
|
||||
indexes = [
|
||||
models.Index(fields=["tag", "sort_order"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.tag.name} - {self.product.name}"
|
||||
|
||||
|
||||
class ProductPrice(models.Model):
|
||||
"""Product prices from different websites."""
|
||||
|
||||
@@ -164,3 +225,35 @@ class ProductPrice(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name} - {self.website.name}: {self.price}"
|
||||
|
||||
|
||||
class ProductPriceHistory(models.Model):
|
||||
"""Historical price records for products."""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
product = models.ForeignKey(
|
||||
Product,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='price_history',
|
||||
verbose_name='商品',
|
||||
)
|
||||
website = models.ForeignKey(
|
||||
Website,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='price_history',
|
||||
verbose_name='网站',
|
||||
)
|
||||
price = models.DecimalField('价格', max_digits=10, decimal_places=2)
|
||||
recorded_at = models.DateTimeField('记录时间', auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'product_price_history'
|
||||
verbose_name = '商品价格历史'
|
||||
verbose_name_plural = '商品价格历史'
|
||||
ordering = ['-recorded_at']
|
||||
indexes = [
|
||||
models.Index(fields=["product", "website", "recorded_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name} - {self.website.name}: {self.price}"
|
||||
|
||||
@@ -72,6 +72,39 @@ class ProductPriceOut(Schema):
|
||||
last_checked: datetime
|
||||
|
||||
|
||||
class ProductPriceHistoryOut(Schema):
|
||||
"""Product price history output schema."""
|
||||
id: int
|
||||
product_id: int
|
||||
website_id: int
|
||||
price: Decimal
|
||||
recorded_at: datetime
|
||||
|
||||
|
||||
class PriceHistoryStatsOut(Schema):
|
||||
"""Price history statistics output schema."""
|
||||
product_id: int
|
||||
historical_lowest: Optional[Decimal] = None
|
||||
historical_lowest_date: Optional[datetime] = None
|
||||
historical_highest: Optional[Decimal] = None
|
||||
historical_highest_date: Optional[datetime] = None
|
||||
average_price: Optional[Decimal] = None
|
||||
current_lowest: Optional[Decimal] = None
|
||||
is_historical_lowest: bool = False
|
||||
discount_from_highest: Optional[Decimal] = None # 距最高价降幅百分比
|
||||
distance_to_lowest: Optional[Decimal] = None # 距历史最低差额
|
||||
price_trend: str = "stable" # up, down, stable
|
||||
history: List["ProductPriceHistoryOut"] = []
|
||||
|
||||
|
||||
class EnhancedProductPriceOut(ProductPriceOut):
|
||||
"""Enhanced product price with history stats."""
|
||||
historical_lowest: Optional[Decimal] = None
|
||||
is_at_historical_lowest: bool = False
|
||||
discount_percent: Optional[Decimal] = None # 降价百分比
|
||||
discount_amount: Optional[Decimal] = None # 降价金额
|
||||
|
||||
|
||||
class ProductOut(Schema):
|
||||
"""Product output schema."""
|
||||
id: int
|
||||
@@ -95,6 +128,43 @@ class ProductWithPricesOut(ProductOut):
|
||||
highest_price: Optional[Decimal] = None
|
||||
|
||||
|
||||
class ComparisonTagOut(Schema):
|
||||
"""Comparison tag output schema."""
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
cover_image: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
product_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ComparisonTagItemOut(Schema):
|
||||
"""Comparison tag item output schema."""
|
||||
product: ProductOut
|
||||
prices: List["EnhancedProductPriceOut"] = []
|
||||
lowest_price: Optional[Decimal] = None
|
||||
highest_price: Optional[Decimal] = None
|
||||
platform_count: int = 0
|
||||
# 增强字段
|
||||
historical_lowest: Optional[Decimal] = None
|
||||
historical_lowest_date: Optional[datetime] = None
|
||||
is_at_historical_lowest: bool = False
|
||||
discount_from_highest_percent: Optional[Decimal] = None
|
||||
is_recommended: bool = False # 推荐标签
|
||||
recommendation_reason: Optional[str] = None # 推荐理由
|
||||
|
||||
|
||||
class ComparisonTagDetailOut(Schema):
|
||||
"""Comparison tag detail output schema."""
|
||||
tag: ComparisonTagOut
|
||||
items: List[ComparisonTagItemOut] = []
|
||||
|
||||
|
||||
class UploadImageOut(Schema):
|
||||
"""Image upload output."""
|
||||
url: str
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.2.27 on 2026-02-03 09:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0005_add_user_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='user',
|
||||
index=models.Index(fields=['user_id'], name='users_user_id_83dd09_idx'),
|
||||
),
|
||||
]
|
||||
@@ -10,13 +10,12 @@ from ninja_jwt.authentication import JWTAuth
|
||||
# Import routers from apps
|
||||
from apps.users.api import router as auth_router
|
||||
from apps.users.friends import router as friends_router
|
||||
from apps.products.api import router as products_router, category_router, website_router
|
||||
from apps.products.api import router as products_router, category_router, website_router, compare_tag_router
|
||||
from apps.bounties.api import router as bounties_router
|
||||
from apps.bounties.payments import router as payments_router
|
||||
from apps.favorites.api import router as favorites_router
|
||||
from apps.notifications.api import router as notifications_router
|
||||
from apps.admin.api import router as admin_router
|
||||
from config.search import router as search_router
|
||||
from apps.common.errors import build_error_payload
|
||||
|
||||
# Create main API instance
|
||||
@@ -69,9 +68,9 @@ api.add_router("/friends/", friends_router, tags=["好友"])
|
||||
api.add_router("/categories/", category_router, tags=["分类"])
|
||||
api.add_router("/websites/", website_router, tags=["网站"])
|
||||
api.add_router("/products/", products_router, tags=["商品"])
|
||||
api.add_router("/compare-tags/", compare_tag_router, tags=["比价标签"])
|
||||
api.add_router("/bounties/", bounties_router, tags=["悬赏"])
|
||||
api.add_router("/payments/", payments_router, tags=["支付"])
|
||||
api.add_router("/favorites/", favorites_router, tags=["收藏"])
|
||||
api.add_router("/notifications/", notifications_router, tags=["通知"])
|
||||
api.add_router("/search/", search_router, tags=["搜索"])
|
||||
api.add_router("/admin/", admin_router, tags=["后台"])
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
"""
|
||||
Global search API routes.
|
||||
"""
|
||||
from typing import List
|
||||
from ninja import Router, Schema
|
||||
from django.db.models import Count, Q
|
||||
from django.conf import settings
|
||||
from django.views.decorators.cache import cache_page
|
||||
|
||||
from apps.products.models import Product, Website
|
||||
from apps.products.schemas import ProductOut, WebsiteOut
|
||||
from apps.bounties.models import Bounty
|
||||
from apps.bounties.schemas import BountyWithDetailsOut
|
||||
from apps.common.serializers import serialize_bounty
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class SearchResultsOut(Schema):
|
||||
products: List[ProductOut]
|
||||
websites: List[WebsiteOut]
|
||||
bounties: List[BountyWithDetailsOut]
|
||||
|
||||
|
||||
def _serialize_bounty_with_counts(bounty):
|
||||
return serialize_bounty(bounty, include_counts=True)
|
||||
|
||||
|
||||
@router.get("/", response=SearchResultsOut)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def global_search(request, q: str, limit: int = 10):
|
||||
"""Search products, websites and bounties by keyword."""
|
||||
keyword = (q or "").strip()
|
||||
if not keyword:
|
||||
return SearchResultsOut(products=[], websites=[], bounties=[])
|
||||
|
||||
products = list(
|
||||
Product.objects.select_related("category").filter(
|
||||
Q(name__icontains=keyword) | Q(description__icontains=keyword)
|
||||
).order_by("-created_at")[:limit]
|
||||
)
|
||||
|
||||
websites = list(
|
||||
Website.objects.select_related("category").filter(
|
||||
Q(name__icontains=keyword) | Q(description__icontains=keyword)
|
||||
).order_by("-created_at")[:limit]
|
||||
)
|
||||
|
||||
bounties = list(
|
||||
Bounty.objects.select_related("publisher", "acceptor")
|
||||
.annotate(
|
||||
applications_count=Count("applications", distinct=True),
|
||||
comments_count=Count("comments", distinct=True),
|
||||
)
|
||||
.filter(Q(title__icontains=keyword) | Q(description__icontains=keyword))
|
||||
.order_by("-created_at")[:limit]
|
||||
)
|
||||
|
||||
return SearchResultsOut(
|
||||
products=products,
|
||||
websites=websites,
|
||||
bounties=[_serialize_bounty_with_counts(b) for b in bounties],
|
||||
)
|
||||
BIN
backend/media/products/50b85ed16f4d40b698c57925cf35738c.jpg
Normal file
BIN
backend/media/products/50b85ed16f4d40b698c57925cf35738c.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
BIN
backend/media/products/a793c69e92bc4bceb76a758662ef51fc.jpg
Normal file
BIN
backend/media/products/a793c69e92bc4bceb76a758662ef51fc.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
Reference in New Issue
Block a user