[api][shop] add product/search、product/create、order/batch (#2)
This commit was merged in pull request #2.
This commit is contained in:
@@ -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"
|
||||
@@ -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}:*")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"), # 批量下单接口
|
||||
]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user