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)