baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OpenApiConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.openapi"
|
||||
verbose_name = "开放 API 与第三方对接平台"
|
||||
@@ -0,0 +1,73 @@
|
||||
"""开放平台认证与授权:
|
||||
APIKeyAuthentication 支持第三方系统通过 Header:
|
||||
X-API-Key: dh_xxxx.yyyy
|
||||
或
|
||||
Authorization: Api-Key dh_xxxx.yyyy
|
||||
安全访问开放接口。
|
||||
"""
|
||||
|
||||
from rest_framework import authentication, exceptions, permissions
|
||||
from django.utils import timezone
|
||||
from asgiref.sync import sync_to_async
|
||||
from .models import APIKey
|
||||
|
||||
|
||||
class APIKeyAuthentication(authentication.BaseAuthentication):
|
||||
"""基于 API Key 的第三方系统鉴权类。"""
|
||||
|
||||
def authenticate(self, request):
|
||||
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||
api_key_header = request.META.get("HTTP_X_API_KEY", "")
|
||||
|
||||
raw_key = None
|
||||
if api_key_header:
|
||||
raw_key = api_key_header.strip()
|
||||
elif auth_header.startswith("Api-Key "):
|
||||
raw_key = auth_header[8:].strip()
|
||||
|
||||
if not raw_key:
|
||||
return None
|
||||
|
||||
if "." not in raw_key:
|
||||
raise exceptions.AuthenticationFailed("API Key 格式无效(必须包含前缀)")
|
||||
|
||||
prefix = raw_key.split(".")[0]
|
||||
key_obj = APIKey.objects.filter(prefix=prefix, is_active=True).select_related("tenant").first()
|
||||
if not key_obj:
|
||||
raise exceptions.AuthenticationFailed("API Key 不存在或已被禁用")
|
||||
|
||||
if key_obj.expires_at and key_obj.expires_at < timezone.now():
|
||||
raise exceptions.AuthenticationFailed("API Key 已过期")
|
||||
|
||||
if not key_obj.verify_key(raw_key):
|
||||
raise exceptions.AuthenticationFailed("API Key 签名无效")
|
||||
|
||||
# 记录调用时间并绑定 tenant
|
||||
key_obj.last_used_at = timezone.now()
|
||||
key_obj.save(update_fields=["last_used_at"])
|
||||
|
||||
# 注入租户上下文
|
||||
request.tenant_obj = key_obj.tenant
|
||||
if hasattr(request, "_request"):
|
||||
request._request.tenant_obj = key_obj.tenant
|
||||
|
||||
# 绑定系统用户
|
||||
user = key_obj.created_by
|
||||
if not user:
|
||||
from django.contrib.auth import get_user_model
|
||||
user = get_user_model().objects.filter(is_superuser=True).first()
|
||||
|
||||
return (user, key_obj)
|
||||
|
||||
|
||||
def require_scope(scope_name: str):
|
||||
"""用于视图权限检查的 Scope 装饰辅助类。"""
|
||||
|
||||
class ScopePermission(permissions.BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
api_key = getattr(request, "auth", None)
|
||||
if not isinstance(api_key, APIKey):
|
||||
return False # 未携带有效 APIKey 则拒绝访问
|
||||
return scope_name in api_key.scopes or "*" in api_key.scopes
|
||||
|
||||
return ScopePermission
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.2.12 on 2026-09-07 02:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='APIKey',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ext_data', models.JSONField(blank=True, default=dict)),
|
||||
('source_channel', models.CharField(blank=True, default='manual', max_length=32)),
|
||||
('name', models.CharField(help_text="接入方系统名称,如'下游分销A系统'", max_length=128)),
|
||||
('prefix', models.CharField(db_index=True, help_text='Key前缀用于检索', max_length=16)),
|
||||
('hashed_key', models.CharField(help_text='SHA256 哈希值', max_length=128)),
|
||||
('scopes', models.JSONField(blank=True, default=list, help_text='授权的作用域列表')),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('rate_limit', models.IntegerField(default=120, help_text='每分钟最大请求次数')),
|
||||
('expires_at', models.DateTimeField(blank=True, help_text='过期时间', null=True)),
|
||||
('last_used_at', models.DateTimeField(blank=True, help_text='最后一次调用时间', null=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('org', models.ForeignKey(blank=True, help_text='所属组织(多机构预留)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.org')),
|
||||
('tenant', models.ForeignKey(help_text='所属租户', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.tenant')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': '开放平台APIKey',
|
||||
'db_table': 'openapi_api_key',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""开放平台模型:
|
||||
APIKey 外部系统访问令牌(哈希安全存储、Scope 细粒度权限控制、调用频率与最后使用审计)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from django.db import models
|
||||
from apps.core.base_models import TenantScopedModel
|
||||
|
||||
|
||||
class APIKey(TenantScopedModel):
|
||||
"""外部第三方对接 API Key。"""
|
||||
|
||||
name = models.CharField(max_length=128, help_text="接入方系统名称,如'下游分销A系统'")
|
||||
prefix = models.CharField(max_length=16, db_index=True, help_text="Key前缀用于检索")
|
||||
hashed_key = models.CharField(max_length=128, help_text="SHA256 哈希值")
|
||||
|
||||
# 权限范围:["products:read", "stocks:read", "orders:write"]
|
||||
scopes = models.JSONField(default=list, blank=True, help_text="授权的作用域列表")
|
||||
is_active = models.BooleanField(default=True)
|
||||
rate_limit = models.IntegerField(default=120, help_text="每分钟最大请求次数")
|
||||
|
||||
expires_at = models.DateTimeField(null=True, blank=True, help_text="过期时间")
|
||||
last_used_at = models.DateTimeField(null=True, blank=True, help_text="最后一次调用时间")
|
||||
|
||||
class Meta:
|
||||
db_table = "openapi_api_key"
|
||||
verbose_name_plural = "开放平台APIKey"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.prefix}****)"
|
||||
|
||||
@classmethod
|
||||
def generate(cls, tenant, name: str, scopes: list = None, expires_at=None, created_by=None):
|
||||
"""生成并安全存储一个新的 API Key,返回 (instance, raw_key_string)。
|
||||
注意:raw_key_string 仅在创建时返回一次!
|
||||
"""
|
||||
raw_secret = secrets.token_urlsafe(32)
|
||||
prefix = f"dh_{secrets.token_hex(4)}"
|
||||
raw_key = f"{prefix}.{raw_secret}"
|
||||
hashed_key = hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
||||
|
||||
instance = cls.objects.create(
|
||||
tenant=tenant,
|
||||
name=name,
|
||||
prefix=prefix,
|
||||
hashed_key=hashed_key,
|
||||
scopes=scopes or ["products:read", "stocks:read", "orders:write"],
|
||||
expires_at=expires_at,
|
||||
created_by=created_by,
|
||||
)
|
||||
return instance, raw_key
|
||||
|
||||
def verify_key(self, raw_key: str) -> bool:
|
||||
"""校验原始 Key 与哈希是否匹配。"""
|
||||
computed = hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
||||
return secrets.compare_digest(self.hashed_key, computed)
|
||||
@@ -0,0 +1,33 @@
|
||||
from rest_framework import serializers
|
||||
from apps.core.serializers import AsyncModelSerializer
|
||||
from .models import APIKey
|
||||
|
||||
|
||||
class APIKeySerializer(AsyncModelSerializer):
|
||||
class Meta:
|
||||
model = APIKey
|
||||
fields = [
|
||||
"id", "name", "prefix", "scopes", "is_active",
|
||||
"rate_limit", "expires_at", "last_used_at",
|
||||
"created_at", "updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "prefix", "last_used_at", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class APIKeyCreateInputSerializer(serializers.Serializer):
|
||||
name = serializers.CharField(max_length=128)
|
||||
scopes = serializers.ListField(child=serializers.CharField(), required=False)
|
||||
expires_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class ExternalOrderLineInputSerializer(serializers.Serializer):
|
||||
product_code = serializers.CharField(max_length=64)
|
||||
quantity = serializers.DecimalField(max_digits=18, decimal_places=4)
|
||||
unit_price = serializers.DecimalField(max_digits=18, decimal_places=4, required=False)
|
||||
|
||||
|
||||
class ExternalOrderCreateSerializer(serializers.Serializer):
|
||||
customer_code = serializers.CharField(max_length=64)
|
||||
warehouse_code = serializers.CharField(max_length=64, required=False)
|
||||
remark = serializers.CharField(max_length=255, required=False, allow_blank=True, default="")
|
||||
lines = ExternalOrderLineInputSerializer(many=True)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""开放平台路由。"""
|
||||
|
||||
from django.urls import path, include
|
||||
from adrf.routers import DefaultRouter as ADRouter
|
||||
from .views import (
|
||||
APIKeyViewSet,
|
||||
OpenProductListView,
|
||||
OpenStockListView,
|
||||
OpenOrderCreateView,
|
||||
)
|
||||
|
||||
|
||||
router = ADRouter()
|
||||
router.register("keys", APIKeyViewSet, basename="openapi-key")
|
||||
|
||||
urlpatterns = [
|
||||
# 管理接口
|
||||
path("", include(router.urls)),
|
||||
# 面向第三方的业务调用端点
|
||||
path("v1/products/", OpenProductListView.as_view(), name="open-products"),
|
||||
path("v1/stocks/", OpenStockListView.as_view(), name="open-stocks"),
|
||||
path("v1/orders/", OpenOrderCreateView.as_view(), name="open-orders"),
|
||||
]
|
||||
@@ -0,0 +1,145 @@
|
||||
"""开放平台接口实现:
|
||||
1. APIKeyViewSet: 面向内部管理员的密钥管理(生成、吊销、列表)
|
||||
2. 开放业务端点(OpenProductView, OpenStockView, OpenOrderView):
|
||||
面向第三方 ERP / 下游分销 / 电商对接系统,使用 APIKeyAuthentication 认证。
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from rest_framework import status, views, permissions
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.core import audit as audit_log
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from asgiref.sync import sync_to_async
|
||||
from adrf.viewsets import ViewSet
|
||||
|
||||
from apps.core.viewset import BaseTenantViewSet
|
||||
from apps.catalog.models import Product
|
||||
from apps.inventory.models import Stock, Warehouse
|
||||
from apps.partner.models import Customer
|
||||
from apps.sales.models import SalesBill, SalesBillLine
|
||||
from apps.sales import services as sales_services
|
||||
|
||||
from .models import APIKey
|
||||
from .serializers import (
|
||||
APIKeySerializer, APIKeyCreateInputSerializer,
|
||||
ExternalOrderCreateSerializer,
|
||||
)
|
||||
from .auth import APIKeyAuthentication, require_scope
|
||||
|
||||
|
||||
class APIKeyViewSet(BaseTenantViewSet):
|
||||
"""内部管理:API Key 维护。"""
|
||||
model = APIKey
|
||||
serializer_class = APIKeySerializer
|
||||
search_fields = ["name", "prefix"]
|
||||
|
||||
async def acreate(self, request, *args, **kwargs):
|
||||
tenant = await self.get_tenant()
|
||||
if not tenant:
|
||||
raise ValidationError({"tenant": "无法识别租户"})
|
||||
|
||||
serializer = APIKeyCreateInputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
user = request.user if request.user.is_authenticated else None
|
||||
key_obj, raw_key = await sync_to_async(APIKey.generate)(
|
||||
tenant=tenant,
|
||||
name=data["name"],
|
||||
scopes=data.get("scopes"),
|
||||
expires_at=data.get("expires_at"),
|
||||
created_by=user,
|
||||
)
|
||||
|
||||
res_data = APIKeySerializer(key_obj).data
|
||||
res_data["raw_key"] = raw_key # 仅在创建时返回一次明文密钥!
|
||||
return Response(res_data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class OpenProductListView(views.APIView):
|
||||
"""第三方开放接口:获取商品列表及定价。"""
|
||||
authentication_classes = [APIKeyAuthentication]
|
||||
permission_classes = [require_scope("products:read")]
|
||||
|
||||
def get(self, request):
|
||||
tenant = getattr(request, "tenant_obj", None) or getattr(getattr(request, "_request", None), "tenant_obj", None)
|
||||
products = Product.objects.filter(tenant=tenant, status="active").values(
|
||||
"code", "name", "spec", "barcode", "sale_price"
|
||||
)
|
||||
return Response({"count": len(products), "results": list(products)})
|
||||
|
||||
|
||||
class OpenStockListView(views.APIView):
|
||||
"""第三方开放接口:实时查询商品可用库存。"""
|
||||
authentication_classes = [APIKeyAuthentication]
|
||||
permission_classes = [require_scope("stocks:read")]
|
||||
|
||||
def get(self, request):
|
||||
tenant = getattr(request, "tenant_obj", None) or getattr(getattr(request, "_request", None), "tenant_obj", None)
|
||||
stocks = Stock.objects.filter(tenant=tenant, on_hand__gt=0).select_related("product", "warehouse")
|
||||
results = []
|
||||
for s in stocks:
|
||||
results.append({
|
||||
"product_code": s.product.code,
|
||||
"product_name": s.product.name,
|
||||
"warehouse_code": s.warehouse.code,
|
||||
"warehouse_name": s.warehouse.name,
|
||||
"on_hand": s.on_hand,
|
||||
"available": s.on_hand - s.locked,
|
||||
})
|
||||
return Response({"count": len(results), "results": results})
|
||||
|
||||
|
||||
class OpenOrderCreateView(views.APIView):
|
||||
"""第三方开放接口:外部系统推送销售开单。"""
|
||||
authentication_classes = [APIKeyAuthentication]
|
||||
permission_classes = [require_scope("orders:write")]
|
||||
|
||||
def post(self, request):
|
||||
tenant = getattr(request, "tenant_obj", None) or getattr(getattr(request, "_request", None), "tenant_obj", None)
|
||||
serializer = ExternalOrderCreateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
customer = Customer.objects.filter(tenant=tenant, code=data["customer_code"], is_active=True).first()
|
||||
if not customer:
|
||||
return Response({"detail": f"客户编码 {data['customer_code']} 不存在"}, status=400)
|
||||
|
||||
wh_code = data.get("warehouse_code")
|
||||
if wh_code:
|
||||
warehouse = Warehouse.objects.filter(tenant=tenant, code=wh_code, is_active=True).first()
|
||||
else:
|
||||
warehouse = Warehouse.objects.filter(tenant=tenant, is_active=True).first()
|
||||
|
||||
if not warehouse:
|
||||
return Response({"detail": "未指定可用仓库"}, status=400)
|
||||
|
||||
parsed_lines = []
|
||||
for item in data["lines"]:
|
||||
p = Product.objects.filter(tenant=tenant, code=item["product_code"], status="active").first()
|
||||
if not p:
|
||||
p = Product.objects.filter(tenant=tenant, code=item["product_code"]).first()
|
||||
if not p:
|
||||
return Response({"detail": f"商品编码 {item['product_code']} 不存在"}, status=400)
|
||||
parsed_lines.append({
|
||||
"product": p,
|
||||
"quantity": item["quantity"],
|
||||
"unit_price": item.get("unit_price", p.sale_price),
|
||||
})
|
||||
|
||||
bill = sales_services.create_sales_bill(
|
||||
tenant=tenant,
|
||||
customer=customer,
|
||||
warehouse=warehouse,
|
||||
lines=parsed_lines,
|
||||
remark=f"[API接入] {data.get('remark', '')}",
|
||||
)
|
||||
|
||||
return Response({
|
||||
"ok": True,
|
||||
"bill_no": bill.bill_no,
|
||||
"total_amount": bill.total_amount,
|
||||
"status": bill.state,
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
Reference in New Issue
Block a user