baseline: 批次A-D 成果 + membership 半成品(测试红)

This commit is contained in:
agent
2026-09-11 23:11:35 +08:00
commit b3f3095d53
311 changed files with 40540 additions and 0 deletions
View File
+7
View File
@@ -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 与第三方对接平台"
+73
View File
@@ -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'],
},
),
]
+58
View File
@@ -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)
+33
View File
@@ -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)
+23
View File
@@ -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"),
]
+145
View File
@@ -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)