diff --git a/api_v2/tests.py b/api_v2/tests.py index 22c072f..e770acd 100644 --- a/api_v2/tests.py +++ b/api_v2/tests.py @@ -10,9 +10,11 @@ from rest_framework.test import APIClient, APIRequestFactory from basic_info import models as basic_models from printing import models as printing_models from business import models as business_models +from shipment import models as shipment_models from api_v2.views.printing import PrintingJobByCustomerView from django.contrib.contenttypes.models import ContentType from stateflow import models as stateflow_models +from django.test.utils import override_settings class QuickCreateEmployeeUserAPITest(TestCase): @@ -277,6 +279,144 @@ class PrintingJobByCustomerAPITest(TestCase): self.assertEqual(data['billed_quantity'], '20.00') +@override_settings(AGENT_ACCESS_KEY='agent-test-key') +class AgentUnshippedShipmentListAPITest(TestCase): + def setUp(self): + self.client = APIClient() + self.url = '/api/v2/ai/shipments/unshipped/' + + self.merchant = basic_models.Merchant.objects.create( + name='Agent商户', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + self.other_merchant = basic_models.Merchant.objects.create( + name='其他商户', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + self.customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name='客户A', + area='华东', + created_by=None, + ) + self.other_customer = basic_models.Customer.objects.create( + merchant=self.other_merchant, + name='客户B', + area='华东', + created_by=None, + ) + + self.unshipped_target = shipment_models.Shipment.objects.create( + merchant=self.merchant, + customer=self.customer, + shipment_date=datetime.date(2026, 4, 14), + area='华东', + address='', + contact_name='张三', + contact_phone='13800000000', + remark='目标记录', + ) + self.unshipped_other_area = shipment_models.Shipment.objects.create( + merchant=self.merchant, + customer=self.customer, + shipment_date=datetime.date(2026, 4, 14), + area='华南', + ) + delivery = shipment_models.ShipmentDelivery.objects.create( + merchant=self.merchant, + driver_name='李司机', + vehicle_trip='TRIP-001', + ) + self.shipped_shipment = shipment_models.Shipment.objects.create( + merchant=self.merchant, + customer=self.customer, + shipment_date=datetime.date(2026, 4, 14), + area='华东', + delivery=delivery, + ) + self.other_merchant_shipment = shipment_models.Shipment.objects.create( + merchant=self.other_merchant, + customer=self.other_customer, + shipment_date=datetime.date(2026, 4, 14), + area='华东', + ) + + def _headers(self, key='agent-test-key'): + return {'HTTP_AUTHORIZATION': key} + + def test_list_unshipped_shipments_success(self): + response = self.client.get( + self.url, + {'merchant_id': self.merchant.id, 'area': '华东'}, + **self._headers(), + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['count'], 1) + self.assertEqual(response.data['results'][0]['id'], self.unshipped_target.id) + self.assertEqual(response.data['results'][0]['customer_name'], '客户A') + self.assertIsNone(response.data['results'][0]['delivery']) + + def test_list_unshipped_shipments_requires_access_key(self): + response = self.client.get( + self.url, + {'merchant_id': self.merchant.id, 'area': '华东'}, + ) + self.assertEqual(response.status_code, 401) + + def test_list_unshipped_shipments_rejects_invalid_access_key(self): + response = self.client.get( + self.url, + {'merchant_id': self.merchant.id, 'area': '华东'}, + **self._headers(key='wrong-key'), + ) + self.assertEqual(response.status_code, 401) + + def test_list_unshipped_shipments_requires_merchant_and_area(self): + response = self.client.get( + self.url, + {'area': '华东'}, + **self._headers(), + ) + self.assertEqual(response.status_code, 400) + self.assertIn('merchant_id', response.data) + + response = self.client.get( + self.url, + {'merchant_id': self.merchant.id}, + **self._headers(), + ) + self.assertEqual(response.status_code, 400) + self.assertIn('area', response.data) + + def test_list_unshipped_shipments_filters_by_merchant(self): + response = self.client.get( + self.url, + {'merchant_id': self.other_merchant.id, 'area': '华东'}, + **self._headers(), + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['count'], 1) + self.assertEqual(response.data['results'][0]['id'], self.other_merchant_shipment.id) + + def test_list_unshipped_shipments_supports_limit_offset(self): + shipment_models.Shipment.objects.create( + merchant=self.merchant, + customer=self.customer, + shipment_date=datetime.date(2026, 4, 15), + area='华东', + remark='第二条', + ) + + response = self.client.get( + self.url, + {'merchant_id': self.merchant.id, 'area': '华东', 'limit': 1, 'offset': 0}, + **self._headers(), + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['count'], 2) + self.assertEqual(len(response.data['results']), 1) + + class PrintingJobBatchAdvanceV2APITest(TestCase): def setUp(self): self.client = APIClient() diff --git a/api_v2/urls.py b/api_v2/urls.py index 403dee0..33d6682 100644 --- a/api_v2/urls.py +++ b/api_v2/urls.py @@ -24,6 +24,7 @@ from api_v2.views import ( MissionReplyListCreateView, MissionReplyRejectView, MissionSetUrgentView, + AgentUnshippedShipmentListView, ) from api_v2.views.basic_info import CustomerEmployeeBindingView, MyVisiblePagesView @@ -44,6 +45,7 @@ urlpatterns = [ path('plate-orders/batch-update/', PlateOrderBatchUpdateView.as_view(), name='api_v2_plate_order_batch_update'), path('stateflow/business-objects/clone/', BusinessObjectCloneView.as_view(), name='api_v2_stateflow_business_object_clone'), path('content-types/', ContentTypeListView.as_view(), name='api_v2_content_type_list'), + path('ai/shipments/unshipped/', AgentUnshippedShipmentListView.as_view(), name='api_v2_ai_unshipped_shipment_list'), path('mission-categories/', MissionCategoryListCreateView.as_view(), name='api_v2_mission_category_list_create'), path('mission-categories//', MissionCategoryDetailView.as_view(), name='api_v2_mission_category_detail'), path('missions/', MissionListCreateView.as_view(), name='api_v2_mission_list_create'), diff --git a/api_v2/views/__init__.py b/api_v2/views/__init__.py index cd8e624..6e38be1 100644 --- a/api_v2/views/__init__.py +++ b/api_v2/views/__init__.py @@ -29,6 +29,7 @@ from .mission import ( MissionReplyRejectView, MissionSetUrgentView, ) +from .ai import AgentUnshippedShipmentListView __all__ = [ 'HealthCheckView', @@ -55,4 +56,5 @@ __all__ = [ 'MissionReplyRejectView', 'MissionSetUrgentView', 'ContentTypeListView', + 'AgentUnshippedShipmentListView', ] diff --git a/api_v2/views/ai.py b/api_v2/views/ai.py new file mode 100644 index 0000000..1669a58 --- /dev/null +++ b/api_v2/views/ai.py @@ -0,0 +1,136 @@ +import logging + +from django.conf import settings +from rest_framework import authentication, mixins, serializers +from rest_framework.authentication import get_authorization_header +from rest_framework.exceptions import AuthenticationFailed +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import IsAuthenticated + +from basic_info import models as basic_models +from flower.viewsets import LimitedLimitOffsetPagination +from shipment.models import Shipment + +logger = logging.getLogger(__name__) + + +class AgentAccessPrincipal: + id = None + username = "agent" + is_staff = False + is_superuser = False + is_anonymous = False + is_authenticated = True + + +class AgentAccessKeyAuthentication(authentication.BaseAuthentication): + """ + 使用固定 AGENT_ACCESS_KEY 的轻量认证。 + + 约定: + - 通过 Authorization 头直接传入密钥 + - 不使用 Bearer 前缀 + """ + + def authenticate(self, request): + configured_key = str(getattr(settings, "AGENT_ACCESS_KEY", "") or "").strip() + if not configured_key: + logger.warning("Agent API authentication failed: AGENT_ACCESS_KEY is not configured") + raise AuthenticationFailed("AGENT_ACCESS_KEY 未配置") + + provided_key = get_authorization_header(request).decode("utf-8").strip() + if not provided_key: + raise AuthenticationFailed("缺少 Authorization 请求头") + + if provided_key != configured_key: + raise AuthenticationFailed("AGENT_ACCESS_KEY 无效") + + return AgentAccessPrincipal(), None + + def authenticate_header(self, request): + return "AgentAccessKey" + + +class AgentUnshippedShipmentQuerySerializer(serializers.Serializer): + merchant_id = serializers.IntegerField(min_value=1) + area = serializers.CharField(allow_blank=False, max_length=30) + limit = serializers.IntegerField(required=False, min_value=1) + offset = serializers.IntegerField(required=False, min_value=0) + + def validate(self, attrs): + area = attrs["area"].strip() + if not area: + raise serializers.ValidationError({"area": "地区不能为空"}) + + merchant = basic_models.Merchant.objects.filter(id=attrs["merchant_id"]).first() + if merchant is None: + raise serializers.ValidationError({"merchant_id": "商户不存在"}) + + attrs["area"] = area + attrs["merchant"] = merchant + return attrs + + +class AgentShipmentListSerializer(serializers.ModelSerializer): + customer_name = serializers.CharField(source="customer.name", read_only=True) + status_display = serializers.CharField(source="get_status_display", read_only=True) + + class Meta: + model = Shipment + fields = [ + "id", + "merchant_id", + "customer", + "customer_name", + "shipment_date", + "address", + "contact_name", + "contact_phone", + "area", + "remark", + "status", + "status_display", + "external_id", + "delivery", + "created_at", + "updated_at", + ] + + +class AgentUnshippedShipmentListView(mixins.ListModelMixin, GenericAPIView): + """ + Agent 专用:查询尚未进入送货单的出货单列表。 + + GET /api/v2/ai/shipments/unshipped/?merchant_id=&area=<地区>&limit=&offset= + """ + + authentication_classes = [AgentAccessKeyAuthentication] + permission_classes = [IsAuthenticated] + serializer_class = AgentShipmentListSerializer + pagination_class = LimitedLimitOffsetPagination + + def get(self, request): + query_serializer = AgentUnshippedShipmentQuerySerializer(data=request.query_params) + query_serializer.is_valid(raise_exception=True) + self.validated_query = query_serializer.validated_data + return self.list(request) + + def get_queryset(self): + validated_query = getattr(self, "validated_query", None) + if validated_query is None: + query_serializer = AgentUnshippedShipmentQuerySerializer(data=self.request.query_params) + query_serializer.is_valid(raise_exception=True) + validated_query = query_serializer.validated_data + + merchant = validated_query["merchant"] + area = validated_query["area"] + + return ( + Shipment.objects.filter( + merchant=merchant, + area=area, + delivery__isnull=True, + ) + .select_related("customer", "merchant") + .order_by("-created_at", "-id") + ) diff --git a/docs/api_v2_ai_api.md b/docs/api_v2_ai_api.md new file mode 100644 index 0000000..86fcbeb --- /dev/null +++ b/docs/api_v2_ai_api.md @@ -0,0 +1,93 @@ +# API v2 Agent 接口 + +本文档描述 `api/v2/ai/...` 下供内部 agent 调用的简化鉴权接口。 + +## 鉴权方式 + +- 使用请求头 `Authorization` +- 直接传入固定密钥 +- 不使用 `Bearer` 前缀 + +示例: + +```bash +curl -X GET \ + 'https://api.example.com/api/v2/ai/shipments/unshipped/?merchant_id=1&area=华东' \ + -H 'Authorization: your-agent-access-key' +``` + +配置项: + +- [`AGENT_ACCESS_KEY`](/home/f/coding/flower/flower/settings.py) + +--- + +## 查询未进入送货单的出货单 + +- **URL**: `/api/v2/ai/shipments/unshipped/` +- **Method**: `GET` + +### 查询参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `merchant_id` | int | 是 | 商户 ID | +| `area` | string | 是 | 地区,精确匹配 | +| `limit` | int | 否 | 分页大小 | +| `offset` | int | 否 | 分页偏移 | + +### 业务定义 + +这里的“未出货”定义为: + +- `Shipment.delivery_id is null` + +也就是该出货单尚未进入送货单。 + +### 响应示例 + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 101, + "merchant_id": 1, + "customer": 12, + "customer_name": "客户A", + "shipment_date": "2026-04-14", + "address": "", + "contact_name": "张三", + "contact_phone": "13800000000", + "area": "华东", + "remark": "目标记录", + "status": 1, + "status_display": "草稿(未发布)", + "external_id": null, + "delivery": null, + "created_at": "2026-04-14T10:00:00Z", + "updated_at": "2026-04-14T10:00:00Z" + } + ] +} +``` + +### 错误响应 + +#### 401 Unauthorized + +```json +{ + "detail": "AGENT_ACCESS_KEY 无效" +} +``` + +#### 400 Bad Request + +```json +{ + "merchant_id": ["This field is required."] +} +``` diff --git a/flower/settings.py b/flower/settings.py index 3e83ccf..f076a99 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -86,6 +86,7 @@ WECOM_WEBHOOK_BASE_URL = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send' WECOM_WEBHOOK_KEY = env('WECOM_WEBHOOK_KEY', default='') SPEAK_ENDPOINT = env('SPEAK_ENDPOINT', default='http://8.148.215.233:9004/speak') PRINTING_ORDER_CREATED_SPEECH_ENABLED = False +AGENT_ACCESS_KEY = env('AGENT_ACCESS_KEY', default='') # CORS 配置