Files
ai_web/backend/apps/admin/api.py
2026-02-04 15:25:04 +08:00

572 lines
18 KiB
Python

"""
Admin API routes for managing core data.
"""
from typing import List, Optional
from datetime import datetime
from django.utils import timezone
from ninja import Router, Schema
from ninja.errors import HttpError
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, ComparisonTag, ComparisonTagItem, ProductPrice, ProductPriceHistory
from apps.bounties.models import Bounty, BountyDispute, PaymentEvent
router = Router()
def require_admin(user):
if not user or user.role != 'admin' or not user.is_active:
raise HttpError(403, "仅管理员可访问")
class UserOut(Schema):
id: int
open_id: str
name: Optional[str] = None
role: str
is_active: bool
created_at: datetime
class UserUpdateIn(Schema):
role: Optional[str] = None
is_active: Optional[bool] = None
class SimpleOut(Schema):
id: int
name: str
class MessageOut(Schema):
message: str
class BountyAdminOut(Schema):
id: int
title: str
status: str
reward: str
publisher_id: int
acceptor_id: Optional[int] = None
is_escrowed: bool
is_paid: bool
created_at: datetime
class Config:
from_attributes = True
@staticmethod
def resolve_reward(obj):
return str(obj.reward)
class PaymentEventOut(Schema):
id: int
event_id: str
event_type: str
bounty_id: Optional[int] = None
success: bool
processed_at: datetime
class Config:
from_attributes = True
class DisputeOut(Schema):
id: int
bounty_id: int
initiator_id: int
status: str
created_at: datetime
class Config:
from_attributes = True
@router.get("/users/", response=List[UserOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=20)
def list_users(request):
require_admin(request.auth)
return User.objects.all().order_by('-created_at')
@router.patch("/users/{user_id}", response=UserOut, auth=JWTAuth())
def update_user(request, user_id: int, data: UserUpdateIn):
require_admin(request.auth)
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
raise HttpError(404, "用户不存在")
update_data = data.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(user, key, value)
user.save()
return user
@router.get("/categories/", response=List[SimpleOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=50)
def list_categories(request):
require_admin(request.auth)
return Category.objects.values('id', 'name').order_by('name')
@router.get("/websites/", response=List[SimpleOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=50)
def list_websites(request):
require_admin(request.auth)
return Website.objects.values('id', 'name').order_by('name')
@router.get("/products/", response=List[SimpleOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=50)
def list_products(request):
require_admin(request.auth)
return Product.objects.values('id', 'name').order_by('-created_at')
@router.get("/bounties/", response=List[BountyAdminOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=20)
def list_bounties(request, status: Optional[str] = None):
require_admin(request.auth)
queryset = Bounty.objects.select_related("publisher", "acceptor").all().order_by('-created_at')
if status:
queryset = queryset.filter(status=status)
return queryset
@router.get("/disputes/", response=List[DisputeOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=20)
def list_disputes(request, status: Optional[str] = None):
require_admin(request.auth)
disputes = BountyDispute.objects.all().order_by('-created_at')
if status:
disputes = disputes.filter(status=status)
return disputes
@router.get("/payments/", response=List[PaymentEventOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=20)
def list_payment_events(request):
require_admin(request.auth)
return PaymentEvent.objects.all().order_by('-processed_at')
# ==================== Product Review ====================
class ProductAdminOut(Schema):
"""Product admin output schema with all fields."""
id: int
name: str
description: Optional[str] = None
image: Optional[str] = None
images: List[str] = []
category_id: int
category_name: Optional[str] = None
status: str
submitted_by_id: Optional[int] = None
submitted_by_name: Optional[str] = None
reject_reason: Optional[str] = None
reviewed_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
@staticmethod
def resolve_category_name(obj):
return obj.category.name if obj.category else None
@staticmethod
def resolve_submitted_by_name(obj):
if obj.submitted_by:
return obj.submitted_by.name or obj.submitted_by.open_id
return None
class ProductReviewIn(Schema):
"""Product review input schema."""
approved: bool
reject_reason: Optional[str] = None
class ProductImagesIn(Schema):
"""Product images update input schema."""
images: List[str] = []
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):
"""List all pending products for review."""
require_admin(request.auth)
return Product.objects.select_related("category", "submitted_by").filter(
status='pending'
).order_by('-created_at')
@router.get("/products/all/", response=List[ProductAdminOut], auth=JWTAuth())
@paginate(PageNumberPagination, page_size=20)
def list_all_products(request, status: Optional[str] = None):
"""List all products with optional status filter."""
require_admin(request.auth)
queryset = Product.objects.select_related("category", "submitted_by").order_by('-created_at')
if status:
queryset = queryset.filter(status=status)
return queryset
@router.post("/products/{product_id}/review/", response=ProductAdminOut, auth=JWTAuth())
def review_product(request, product_id: int, data: ProductReviewIn):
"""Approve or reject a product."""
require_admin(request.auth)
try:
product = Product.objects.select_related("category", "submitted_by").get(id=product_id)
except Product.DoesNotExist:
raise HttpError(404, "商品不存在")
if product.status != 'pending':
raise HttpError(400, "只能审核待审核状态的商品")
if data.approved:
product.status = 'approved'
product.reject_reason = None
else:
if not data.reject_reason:
raise HttpError(400, "拒绝时需要提供原因")
product.status = 'rejected'
product.reject_reason = data.reject_reason
product.reviewed_at = timezone.now()
product.save()
return product
@router.put("/products/{product_id}/images/", response=ProductAdminOut, auth=JWTAuth())
def update_product_images(request, product_id: int, data: ProductImagesIn):
require_admin(request.auth)
try:
product = Product.objects.select_related("category", "submitted_by").get(id=product_id)
except Product.DoesNotExist:
raise HttpError(404, "商品不存在")
max_images = 6
images = [url.strip() for url in (data.images or []) if url and url.strip()]
image = (data.image or "").strip() or (images[0] if images else None)
if image and image not in images:
images.insert(0, image)
if len(images) > max_images:
raise HttpError(400, f"最多上传{max_images}张图片")
product.image = image or None
product.images = images
product.save(update_fields=["image", "images", "updated_at"])
return product
# ==================== 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)