This commit is contained in:
27942
2026-02-04 15:25:04 +08:00
parent fc0679b199
commit 1b5adeaf22
33 changed files with 3597 additions and 894 deletions

View File

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

View File

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

View File

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

View File

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

View 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"),
],
},
),
]

View File

@@ -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"),
],
},
),
]

View File

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

View File

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

View File

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

View File

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

View File

@@ -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=["后台"])

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB