[api][shop] add product/searchproduct/createorder/batch (#2)

This commit was merged in pull request #2.
This commit is contained in:
2025-05-28 16:45:58 +00:00
parent f824b600f3
commit 3af067cf3b
18 changed files with 1254 additions and 2 deletions
View File
+8
View File
@@ -0,0 +1,8 @@
from __future__ import annotations
from django.apps import AppConfig
class ShopConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "shop"
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import asyncio
from quantum_stack_interview import settings
from redis import asyncio as aioredis
# 这里因为只有一个地方使用到了 redis, 所以直接在这里创建连接池
redis_pool = aioredis.ConnectionPool.from_url(
f"redis://{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DB}", decode_responses=True
)
class KeywordCache:
_write_lock = asyncio.Lock()
def __init__(self):
self.redis = aioredis.Redis(connection_pool=redis_pool)
self.key_prefix = "product_keyword"
async def get_product_id(self, key: str):
return await self.redis.smembers(f"{self.key_prefix}:{key}")
async def set_product_id(self, key: str, data: list[int]):
async with self._write_lock:
await self.redis.sadd(f"{self.key_prefix}:{key}", *data)
await self.redis.expire(f"{self.key_prefix}:{key}", 3600) # 设置过期时间为1小时
async def add_product_one(self, key: str, product_id: int):
async with self._write_lock:
if self.hash_product(key) is None:
await self.redis.sadd(f"{self.key_prefix}:{key}", product_id)
# await self.redis.expire(f"{self.key_prefix}:{key}", 3600)
async def invalidate_product(self, key):
async with self._write_lock:
await self.redis.delete(f"{self.key_prefix}:{key}")
async def hash_product(self, key: str):
return await self.redis.hgetall(f"{self.key_prefix}:{key}")
async def scan_product_keyword(self):
return await self.redis.scan_iter(f"{self.key_prefix}:*")
+10
View File
@@ -0,0 +1,10 @@
from __future__ import annotations
from django.conf import settings
from django.utils.deprecation import MiddlewareMixin
class DisableCSRFCheck(MiddlewareMixin):
def process_request(self, request):
if settings.DEBUG:
request._dont_enforce_csrf_checks = True
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
from enum import Enum
from typing import ClassVar
from django.db import models
class OrderStatus(Enum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
@classmethod
def choices(cls):
return [(tag.name, tag.value) for tag in cls]
class Product(models.Model):
"""
商品模型
"""
id = models.AutoField(verbose_name="商品ID", primary_key=True)
name = models.CharField(verbose_name="商品名称", max_length=255, db_index=True)
description = models.TextField(verbose_name="商品描述", blank=True)
price = models.DecimalField(verbose_name="商品价格", max_digits=10, decimal_places=2)
stock = models.PositiveIntegerField(verbose_name="商品库存", default=0)
keywords = models.CharField(verbose_name="商品关键词", max_length=255, blank=True)
updated_at = models.DateTimeField(verbose_name="更新时间", auto_now=True)
class Meta:
verbose_name = "商品"
indexes: ClassVar[list[models.Index]] = [
models.Index(fields=["name"], name="idx_product_name"),
models.Index(fields=["keywords"], name="idx_product_keywords"),
]
class Order(models.Model):
"""
订单模型
"""
created_at = models.DateTimeField(verbose_name="创建时间", auto_now_add=True)
status = models.CharField(verbose_name="订单状态", max_length=32, default=OrderStatus.PENDING.value)
class OrderItem(models.Model):
"""
订单项模型
"""
order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE)
product = models.ForeignKey(Product, on_delete=models.PROTECT)
quantity = models.PositiveIntegerField(verbose_name="数量")
price = models.DecimalField(verbose_name="价格", max_digits=10, decimal_places=2)
status = models.CharField(verbose_name="订单项状态", max_length=32, default=OrderStatus.SUCCESS.value)
fail_reason = models.CharField(verbose_name="失败原因", max_length=255, blank=True)
+144
View File
@@ -0,0 +1,144 @@
from __future__ import annotations
import asyncio
from asgiref.sync import sync_to_async
from django.db import transaction
from django.db.models import Q
from shop.cache import KeywordCache
from shop.models import Order, OrderItem, OrderStatus, Product
# 定义最大页面大小, 这个应该放在配置文件中
MAX_PAGE_SIZE = 100
class ProductService:
@staticmethod
async def fuzzy_query(keyword: str, start: int, end: int):
qs = Product.objects.filter(
Q(name__icontains=keyword)
| Q(keywords__icontains=keyword)
| Q(description__icontains=keyword)
| Q(id__icontains=keyword)
)[start:end]
return await sync_to_async(list)(qs)
@staticmethod
async def update_keyword_cache(keyword: str, products: list[Product]):
cache = KeywordCache()
await cache.set_product_id(keyword, [product.id for product in products])
@staticmethod
async def search_products(keyword: str, page: int = 1, page_size: int = 50) -> list[Product]:
page_size = min(page_size, MAX_PAGE_SIZE)
# 只做首页缓存
if page == 1:
cache = KeywordCache()
try:
cached: list[int] = await cache.get_product_id(keyword)
if cached:
if len(cached) < page_size:
products = await ProductService.fuzzy_query(keyword, 0, page_size)
# 可能会丢失停机时的临界缓存, 不过在这是可接受的
asyncio.create_task(ProductService.update_keyword_cache(keyword, products)) # noqa: RUF006
return products
# 只取前 page_size 个 id, 且保持顺序
ids = cached[:page_size]
qs = Product.objects.filter(id__in=ids)
products = await sync_to_async(list)(qs)
# 保证顺序
id2product = {p.id: p for p in products}
return [id2product[i] for i in ids if i in id2product]
except (ConnectionError, TimeoutError):
# 如果缓存查询失败, 继续执行数据库查询
pass
assert page > 0 and page_size > 0
start = (page - 1) * page_size
end = start + page_size
products = await ProductService.fuzzy_query(keyword, start, end)
if page == 1 and len(products) > 0:
asyncio.create_task(ProductService.update_keyword_cache(keyword, products)) # noqa: RUF006
return products
@staticmethod
async def create_product(**kwargs):
product = await Product.objects.acreate(**kwargs)
# TODO:这里的关键词更新是不准确的
keywords = kwargs.get("keywords")
if keywords:
cache = KeywordCache()
asyncio.create_task(cache.invalidate_product(keywords)) # noqa: RUF006
# 返回新商品主要信息
return {
"id": product.id,
"name": product.name,
"description": product.description,
"price": str(product.price),
"stock": product.stock,
"keywords": product.keywords,
}
class OrderService:
@staticmethod
async def batch_create_order(order_items):
def _batch_create_order_sync(order_items):
results = []
with transaction.atomic():
order = Order.objects.create()
for item in order_items:
try:
product = Product.objects.select_for_update().get(id=item["product_id"])
if product.stock >= item["quantity"]:
product.stock -= item["quantity"]
product.save()
OrderItem.objects.create(
order=order,
product=product,
quantity=item["quantity"],
price=product.price,
status=OrderStatus.SUCCESS.value,
)
results.append(
{
"product_id": product.id,
"status": OrderStatus.SUCCESS.value,
},
)
else:
OrderItem.objects.create(
order=order,
product=product,
quantity=item["quantity"],
price=product.price,
status=OrderStatus.FAILED.value,
fail_reason="库存不足",
)
results.append(
{
"product_id": product.id,
"status": OrderStatus.FAILED.value,
"reason": "库存不足",
},
)
except Product.DoesNotExist:
results.append(
{
"product_id": item["product_id"],
"status": OrderStatus.FAILED.value,
"reason": "商品不存在",
}
)
except Exception as e:
results.append(
{
"product_id": item["product_id"],
"status": OrderStatus.FAILED.value,
"reason": str(e),
},
)
return order.id, results
return await sync_to_async(_batch_create_order_sync)(order_items)
+11
View File
@@ -0,0 +1,11 @@
from __future__ import annotations
from django.urls import path
from shop.views import batch_order_view, create_product_view, product_search_view
urlpatterns = [
path("product/search/", product_search_view, name="product_search"), # 商品搜索接口
path("product/create/", create_product_view, name="create_product"), # 新增商品接口
path("order/batch/", batch_order_view, name="batch_order"), # 批量下单接口
]
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import json
from django.http import JsonResponse
from django.views.decorators.http import require_GET, require_POST
from shop.services import OrderService, ProductService
@require_GET
async def product_search_view(request):
keyword = request.GET.get("q", "").strip()
page = int(request.GET.get("page", 1))
page_size = int(request.GET.get("page_size", 50))
if not keyword:
return JsonResponse({"error": "搜索关键字不能为空"}, status=400)
if page <= 0 or page_size <= 0:
return JsonResponse({"error": "页码和每页大小必须大于0"}, status=400)
try:
products = await ProductService.search_products(keyword, page=page, page_size=page_size)
products_data = [
{
"id": p.id,
"name": p.name,
"description": p.description,
"price": str(p.price),
"stock": p.stock,
"keywords": p.keywords,
}
for p in products
]
return JsonResponse({"products": products_data})
except Exception as e:
return JsonResponse({"error": "系统异常", "detail": str(e)}, status=500)
@require_POST
async def batch_order_view(request):
try:
data = json.loads(request.body)
order_items = data.get("items", [])
if not order_items:
return JsonResponse({"error": "订单明细不能为空"}, status=400)
order_id, results = await OrderService.batch_create_order(order_items)
return JsonResponse({"order_id": order_id, "results": results})
except Exception as e:
return JsonResponse({"error": "系统异常", "detail": str(e)}, status=500)
@require_POST
async def create_product_view(request):
try:
data = json.loads(request.body)
product_data = data.get("data", {})
required_fields = ["name", "price", "stock"]
for field in required_fields:
if not product_data.get(field):
return JsonResponse({"error": f"字段 {field} 不能为空"}, status=400)
# 字段类型校验
if not isinstance(product_data["name"], str) or not product_data["name"].strip():
return JsonResponse({"error": "商品名称必须为非空字符串"}, status=400)
try:
from decimal import Decimal
price = Decimal(str(product_data["price"]))
if price <= 0:
return JsonResponse({"error": "商品价格必须为正数"}, status=400)
except Exception:
return JsonResponse({"error": "商品价格格式不正确"}, status=400)
try:
stock = int(product_data["stock"])
if stock < 0:
return JsonResponse({"error": "库存不能为负数"}, status=400)
except Exception:
return JsonResponse({"error": "库存格式不正确"}, status=400)
if "keywords" in product_data and not isinstance(product_data["keywords"], str):
return JsonResponse({"error": "关键词必须为字符串"}, status=400)
result = await ProductService.create_product(**product_data)
return JsonResponse({"result": result})
except Exception as e:
return JsonResponse({"error": "系统异常", "detail": str(e)}, status=500)