Files
ai_web/backend/apps/admin/api.py

572 lines
18 KiB
Python
Raw Normal View History

2026-01-27 18:15:43 +08:00
"""
Admin API routes for managing core data.
"""
from typing import List, Optional
2026-01-28 16:00:56 +08:00
from datetime import datetime
from django.utils import timezone
2026-01-27 18:15:43 +08:00
from ninja import Router, Schema
from ninja.errors import HttpError
from ninja_jwt.authentication import JWTAuth
2026-01-28 16:00:56 +08:00
from ninja.pagination import paginate, PageNumberPagination
2026-01-27 18:15:43 +08:00
from apps.users.models import User
2026-02-04 15:25:04 +08:00
from apps.products.models import Product, Website, Category, ComparisonTag, ComparisonTagItem, ProductPrice, ProductPriceHistory
2026-01-27 18:15:43 +08:00
from apps.bounties.models import Bounty, BountyDispute, PaymentEvent
router = Router()
def require_admin(user):
2026-01-28 16:00:56 +08:00
if not user or user.role != 'admin' or not user.is_active:
2026-01-27 18:15:43 +08:00
raise HttpError(403, "仅管理员可访问")
class UserOut(Schema):
id: int
open_id: str
name: Optional[str] = None
role: str
is_active: bool
2026-01-28 16:00:56 +08:00
created_at: datetime
2026-01-27 18:15:43 +08:00
class UserUpdateIn(Schema):
role: Optional[str] = None
is_active: Optional[bool] = None
class SimpleOut(Schema):
id: int
name: str
2026-02-04 15:25:04 +08:00
class MessageOut(Schema):
message: str
2026-01-27 18:15:43 +08:00
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
2026-01-28 16:00:56 +08:00
created_at: datetime
class Config:
from_attributes = True
@staticmethod
def resolve_reward(obj):
return str(obj.reward)
2026-01-27 18:15:43 +08:00
class PaymentEventOut(Schema):
id: int
event_id: str
event_type: str
bounty_id: Optional[int] = None
success: bool
2026-01-28 16:00:56 +08:00
processed_at: datetime
class Config:
from_attributes = True
2026-01-27 18:15:43 +08:00
class DisputeOut(Schema):
id: int
bounty_id: int
initiator_id: int
status: str
2026-01-28 16:00:56 +08:00
created_at: datetime
class Config:
from_attributes = True
2026-01-27 18:15:43 +08:00
@router.get("/users/", response=List[UserOut], auth=JWTAuth())
2026-01-28 16:00:56 +08:00
@paginate(PageNumberPagination, page_size=20)
2026-01-27 18:15:43 +08:00
def list_users(request):
require_admin(request.auth)
2026-01-28 16:00:56 +08:00
return User.objects.all().order_by('-created_at')
2026-01-27 18:15:43 +08:00
@router.patch("/users/{user_id}", response=UserOut, auth=JWTAuth())
def update_user(request, user_id: int, data: UserUpdateIn):
require_admin(request.auth)
2026-01-28 16:00:56 +08:00
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
raise HttpError(404, "用户不存在")
2026-01-27 18:15:43 +08:00
update_data = data.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(user, key, value)
user.save()
2026-01-28 16:00:56 +08:00
return user
2026-01-27 18:15:43 +08:00
@router.get("/categories/", response=List[SimpleOut], auth=JWTAuth())
2026-01-28 16:00:56 +08:00
@paginate(PageNumberPagination, page_size=50)
2026-01-27 18:15:43 +08:00
def list_categories(request):
require_admin(request.auth)
2026-01-28 16:00:56 +08:00
return Category.objects.values('id', 'name').order_by('name')
2026-01-27 18:15:43 +08:00
@router.get("/websites/", response=List[SimpleOut], auth=JWTAuth())
2026-01-28 16:00:56 +08:00
@paginate(PageNumberPagination, page_size=50)
2026-01-27 18:15:43 +08:00
def list_websites(request):
require_admin(request.auth)
2026-01-28 16:00:56 +08:00
return Website.objects.values('id', 'name').order_by('name')
2026-01-27 18:15:43 +08:00
@router.get("/products/", response=List[SimpleOut], auth=JWTAuth())
2026-01-28 16:00:56 +08:00
@paginate(PageNumberPagination, page_size=50)
2026-01-27 18:15:43 +08:00
def list_products(request):
require_admin(request.auth)
2026-01-28 16:00:56 +08:00
return Product.objects.values('id', 'name').order_by('-created_at')
2026-01-27 18:15:43 +08:00
@router.get("/bounties/", response=List[BountyAdminOut], auth=JWTAuth())
2026-01-28 16:00:56 +08:00
@paginate(PageNumberPagination, page_size=20)
2026-01-27 18:15:43 +08:00
def list_bounties(request, status: Optional[str] = None):
require_admin(request.auth)
2026-01-28 16:00:56 +08:00
queryset = Bounty.objects.select_related("publisher", "acceptor").all().order_by('-created_at')
2026-01-27 18:15:43 +08:00
if status:
queryset = queryset.filter(status=status)
2026-01-28 16:00:56 +08:00
return queryset
2026-01-27 18:15:43 +08:00
@router.get("/disputes/", response=List[DisputeOut], auth=JWTAuth())
2026-01-28 16:00:56 +08:00
@paginate(PageNumberPagination, page_size=20)
2026-01-27 18:15:43 +08:00
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)
2026-01-28 16:00:56 +08:00
return disputes
2026-01-27 18:15:43 +08:00
@router.get("/payments/", response=List[PaymentEventOut], auth=JWTAuth())
2026-01-28 16:00:56 +08:00
@paginate(PageNumberPagination, page_size=20)
2026-01-27 18:15:43 +08:00
def list_payment_events(request):
require_admin(request.auth)
2026-01-28 16:00:56 +08:00
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
2026-01-29 13:18:59 +08:00
images: List[str] = []
2026-01-28 16:00:56 +08:00
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
2026-01-29 13:18:59 +08:00
class ProductImagesIn(Schema):
"""Product images update input schema."""
images: List[str] = []
image: Optional[str] = None
2026-02-04 15:25:04 +08:00
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
2026-01-28 16:00:56 +08:00
@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
2026-01-29 13:18:59 +08:00
@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
2026-02-04 15:25:04 +08:00
# ==================== 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)