1
0
forked from erp-dev/erp

fea: business completed

This commit is contained in:
2026-06-06 14:14:50 +08:00
parent b97e86257e
commit 3b2aacaa5c
12 changed files with 1270 additions and 92 deletions

View File

@@ -0,0 +1,217 @@
"""
企业微信用户绑定 API 测试
"""
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from basic_info.models import Employee, Merchant, MerchantTypeEnum
@override_settings(AGENT_ACCESS_KEY='test-key-123')
class WecomBindingAPITestCase(TestCase):
"""测试企业微信用户绑定接口"""
def setUp(self):
self.client = APIClient()
self.url = '/api/v2/wecom/binduser/'
self.auth_header = 'test-key-123'
# 创建商户
self.merchant = Merchant.objects.create(
name='测试商户',
type=MerchantTypeEnum.STORE,
)
# 创建用户
self.user = User.objects.create_user(
username='testuser',
password='testpass123',
)
# 创建员工并关联用户
self.employee = Employee.objects.create(
merchant=self.merchant,
name='测试员工',
sys_user=self.user,
)
def _post(self, data, auth=None):
"""辅助方法:发送 POST 请求"""
headers = {'HTTP_AUTHORIZATION': auth or self.auth_header}
return self.client.post(self.url, data, format='json', **headers)
# ==================== 认证测试 ====================
def test_missing_authorization_header(self):
"""缺少 Authorization 头应返回 401"""
resp = self.client.post(self.url, {}, format='json')
self.assertEqual(resp.status_code, 401)
def test_invalid_authorization_key(self):
"""错误的 AGENT_ACCESS_KEY 应返回 401"""
resp = self._post(
{'wecom_user_id': 'wx123', 'username': 'testuser', 'password': 'testpass123'},
auth='wrong-key',
)
self.assertEqual(resp.status_code, 401)
# ==================== 参数验证测试 ====================
def test_missing_required_fields(self):
"""缺少必填字段应返回 400"""
resp = self._post({})
self.assertEqual(resp.status_code, 400)
data = resp.json()
self.assertIn('wecom_user_id', data)
self.assertIn('username', data)
self.assertIn('password', data)
def test_missing_wecom_user_id(self):
"""缺少 wecom_user_id 应返回 400"""
resp = self._post({'username': 'testuser', 'password': 'testpass123'})
self.assertEqual(resp.status_code, 400)
self.assertIn('wecom_user_id', resp.json())
# ==================== 用户凭据验证测试 ====================
def test_wrong_password(self):
"""密码错误应返回 401"""
resp = self._post({
'wecom_user_id': 'wx123',
'username': 'testuser',
'password': 'wrongpass',
})
self.assertEqual(resp.status_code, 401)
self.assertEqual(resp.json()['error'], '用户名或密码错误')
def test_nonexistent_username(self):
"""不存在的用户名应返回 401"""
resp = self._post({
'wecom_user_id': 'wx123',
'username': 'nouser',
'password': 'testpass123',
})
self.assertEqual(resp.status_code, 401)
def test_inactive_user(self):
"""被禁用的用户应返回 403"""
self.user.is_active = False
self.user.save()
resp = self._post({
'wecom_user_id': 'wx123',
'username': 'testuser',
'password': 'testpass123',
})
# Django authenticate() returns None for inactive users by default
# so it will be 401 (用户名或密码错误)
self.assertIn(resp.status_code, [401, 403])
def test_user_without_employee(self):
"""用户未关联员工应返回 404"""
user2 = User.objects.create_user(username='noemployee', password='pass123')
resp = self._post({
'wecom_user_id': 'wx123',
'username': 'noemployee',
'password': 'pass123',
})
self.assertEqual(resp.status_code, 404)
self.assertEqual(resp.json()['error'], '该用户未关联任何员工记录')
# ==================== 绑定成功测试 ====================
def test_binding_success(self):
"""正常绑定应返回 200"""
resp = self._post({
'wecom_user_id': 'wx_user_001',
'username': 'testuser',
'password': 'testpass123',
})
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data['message'], '绑定成功')
self.assertEqual(data['employee_id'], self.employee.id)
self.assertEqual(data['employee_name'], '测试员工')
self.assertEqual(data['wecom_user_id'], 'wx_user_001')
# 验证数据库
self.employee.refresh_from_db()
self.assertEqual(self.employee.wecom_user_id, 'wx_user_001')
def test_binding_idempotent(self):
"""重复绑定相同 wecom_user_id 应幂等返回 200"""
self.employee.wecom_user_id = 'wx_user_001'
self.employee.save()
resp = self._post({
'wecom_user_id': 'wx_user_001',
'username': 'testuser',
'password': 'testpass123',
})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()['message'], '绑定成功')
# ==================== 冲突测试 ====================
def test_wecom_user_id_already_bound_to_other_employee(self):
"""wecom_user_id 已绑定到其他员工应返回 409"""
other_employee = Employee.objects.create(
merchant=self.merchant,
name='其他员工',
wecom_user_id='wx_user_001',
)
resp = self._post({
'wecom_user_id': 'wx_user_001',
'username': 'testuser',
'password': 'testpass123',
})
self.assertEqual(resp.status_code, 409)
self.assertIn('该企业微信用户已绑定到其他员工', resp.json()['error'])
def test_employee_already_has_different_wecom_user_id_without_force(self):
"""员工已有不同 wecom_user_id 且未传 force_update 应返回 409"""
self.employee.wecom_user_id = 'wx_old_user'
self.employee.save()
resp = self._post({
'wecom_user_id': 'wx_new_user',
'username': 'testuser',
'password': 'testpass123',
})
self.assertEqual(resp.status_code, 409)
data = resp.json()
self.assertEqual(data['error'], '该员工已绑定其他企业微信用户')
self.assertEqual(data['current_wecom_user_id'], 'wx_old_user')
self.assertIn('hint', data)
def test_employee_already_has_different_wecom_user_id_with_force(self):
"""员工已有不同 wecom_user_id 但传了 force_update=true 应覆盖成功"""
self.employee.wecom_user_id = 'wx_old_user'
self.employee.save()
resp = self._post({
'wecom_user_id': 'wx_new_user',
'username': 'testuser',
'password': 'testpass123',
'force_update': True,
})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()['wecom_user_id'], 'wx_new_user')
self.employee.refresh_from_db()
self.assertEqual(self.employee.wecom_user_id, 'wx_new_user')
def test_force_update_false_explicitly(self):
"""显式传 force_update=false 应拒绝覆盖"""
self.employee.wecom_user_id = 'wx_old_user'
self.employee.save()
resp = self._post({
'wecom_user_id': 'wx_new_user',
'username': 'testuser',
'password': 'testpass123',
'force_update': False,
})
self.assertEqual(resp.status_code, 409)

View File

@@ -0,0 +1,132 @@
"""
企业微信快捷登录 API 测试
"""
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from basic_info.models import Employee, Merchant, MerchantTypeEnum
@override_settings(AGENT_ACCESS_KEY='test-key-123')
class WecomLoginAPITestCase(TestCase):
"""测试企业微信快捷登录接口"""
def setUp(self):
self.client = APIClient()
self.url = '/api/v2/wecom/login/'
self.auth_header = 'test-key-123'
# 创建商户
self.merchant = Merchant.objects.create(
name='测试商户',
type=MerchantTypeEnum.STORE,
)
# 创建用户
self.user = User.objects.create_user(
username='testuser',
password='testpass123',
)
# 创建员工并关联用户,设置 wecom_user_id
self.employee = Employee.objects.create(
merchant=self.merchant,
name='测试员工',
sys_user=self.user,
wecom_user_id='wx_user_001',
)
def _post(self, data, auth=None):
"""辅助方法:发送 POST 请求"""
headers = {'HTTP_AUTHORIZATION': auth or self.auth_header}
return self.client.post(self.url, data, format='json', **headers)
# ==================== 认证测试 ====================
def test_missing_authorization_header(self):
"""缺少 Authorization 头应返回 401"""
resp = self.client.post(self.url, {'wecom_user_id': 'wx_user_001'}, format='json')
self.assertEqual(resp.status_code, 401)
def test_invalid_authorization_key(self):
"""错误的 AGENT_ACCESS_KEY 应返回 401"""
resp = self._post({'wecom_user_id': 'wx_user_001'}, auth='wrong-key')
self.assertEqual(resp.status_code, 401)
# ==================== 参数验证测试 ====================
def test_missing_wecom_user_id(self):
"""缺少 wecom_user_id 应返回 400"""
resp = self._post({})
self.assertEqual(resp.status_code, 400)
self.assertIn('wecom_user_id', resp.json())
# ==================== 登录成功测试 ====================
def test_login_success(self):
"""正常登录应返回 200 和 JWT token"""
resp = self._post({'wecom_user_id': 'wx_user_001'})
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIn('access', data)
self.assertIn('refresh', data)
self.assertEqual(data['employee_id'], self.employee.id)
self.assertEqual(data['employee_name'], '测试员工')
self.assertEqual(data['username'], 'testuser')
# access token 应该是非空字符串
self.assertTrue(len(data['access']) > 0)
self.assertTrue(len(data['refresh']) > 0)
def test_login_token_is_valid_jwt(self):
"""返回的 token 应该可以用于认证后续请求"""
resp = self._post({'wecom_user_id': 'wx_user_001'})
self.assertEqual(resp.status_code, 200)
access_token = resp.json()['access']
# 使用 token 访问需要认证的接口
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}')
# 访问 health check 不需要特殊权限,但需要认证
health_resp = self.client.get('/api/v2/health/')
# health check 应该返回 200不管认证方式
self.assertEqual(health_resp.status_code, 200)
# ==================== 错误场景测试 ====================
def test_unbound_wecom_user_id(self):
"""未绑定的 wecom_user_id 应返回 404"""
resp = self._post({'wecom_user_id': 'wx_unknown_user'})
self.assertEqual(resp.status_code, 404)
self.assertEqual(resp.json()['error'], '该企业微信用户未绑定本系统员工')
def test_employee_without_sys_user(self):
"""员工未关联系统用户应返回 404"""
# 创建一个没有 sys_user 的员工
employee_no_user = Employee.objects.create(
merchant=self.merchant,
name='无用户员工',
wecom_user_id='wx_no_user',
)
resp = self._post({'wecom_user_id': 'wx_no_user'})
self.assertEqual(resp.status_code, 404)
self.assertEqual(resp.json()['error'], '该员工未关联系统用户')
def test_inactive_user(self):
"""被禁用的用户应返回 403"""
self.user.is_active = False
self.user.save()
resp = self._post({'wecom_user_id': 'wx_user_001'})
self.assertEqual(resp.status_code, 403)
self.assertEqual(resp.json()['error'], '该用户已被禁用')
# ==================== 边界测试 ====================
def test_multiple_logins_return_different_tokens(self):
"""多次登录应返回不同的 token每次生成新 token"""
resp1 = self._post({'wecom_user_id': 'wx_user_001'})
resp2 = self._post({'wecom_user_id': 'wx_user_001'})
self.assertEqual(resp1.status_code, 200)
self.assertEqual(resp2.status_code, 200)
# refresh token 每次应该不同
self.assertNotEqual(resp1.json()['refresh'], resp2.json()['refresh'])

View File

@@ -41,6 +41,7 @@ from api_v2.views import (
AgentMesProductionAssignmentListView,
)
from api_v2.views.basic_info import CustomerEmployeeBindingView, MyVisiblePagesView
from api_v2.views.wecom import WecomBindingView, WecomLoginView
urlpatterns = [
path('health/', HealthCheckView.as_view(), name='api_v2_health_check'),
@@ -83,4 +84,6 @@ urlpatterns = [
path('mes/production-assignments/<int:assignment_id>/', ProductionAssignmentDetailView.as_view(), name='api_v2_mes_production_assignment_detail'),
path('shipment-delivery-photos/', ShipmentDeliveryPhotoListCreateView.as_view(), name='api_v2_shipment_delivery_photo_list_create'),
path('shipment-delivery-photos/<int:photo_id>/', ShipmentDeliveryPhotoDetailView.as_view(), name='api_v2_shipment_delivery_photo_detail'),
path('wecom/binduser/', WecomBindingView.as_view(), name='api_v2_wecom_binduser'),
path('wecom/login/', WecomLoginView.as_view(), name='api_v2_wecom_login'),
]

View File

@@ -47,6 +47,7 @@ from .ai import (
AgentMesProductionAssignmentListView,
)
from .shipment_delivery_photo import ShipmentDeliveryPhotoDetailView, ShipmentDeliveryPhotoListCreateView
from .wecom import WecomBindingView, WecomLoginView
__all__ = [
'HealthCheckView',

212
api_v2/views/wecom.py Normal file
View File

@@ -0,0 +1,212 @@
"""
企业微信 API
供外部企业微信服务端程序调用:
1. 绑定接口:将企业微信 user_id 与本系统 Employee 绑定
2. 登录接口:通过已绑定的 wecom_user_id 快捷获取 JWT token
认证方式AGENT_ACCESS_KEY通过 Authorization 头传入)。
"""
import logging
from django.contrib.auth import authenticate
from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import RefreshToken
from api_v2.views.ai import AgentAccessKeyAuthentication
from basic_info.models import Employee
logger = logging.getLogger(__name__)
class WecomBindingRequestSerializer(serializers.Serializer):
wecom_user_id = serializers.CharField(
max_length=128,
help_text='企业微信 OAuth2 返回的 user_id',
)
username = serializers.CharField(
max_length=150,
help_text='本系统用户名',
)
password = serializers.CharField(
max_length=128,
help_text='本系统用户密码',
)
force_update = serializers.BooleanField(
default=False,
required=False,
help_text='如果该 Employee 已绑定其他企业微信用户,是否强制覆盖',
)
class WecomBindingView(APIView):
"""
企业微信用户绑定接口。
POST /api/v2/wecom/binduser/
请求体:
- wecom_user_id: 企业微信用户 ID
- username: 本系统用户名
- password: 本系统用户密码
- force_update: (可选) 是否强制覆盖已有绑定,默认 false
认证Authorization 头传入 AGENT_ACCESS_KEY
"""
authentication_classes = [AgentAccessKeyAuthentication]
permission_classes = [IsAuthenticated]
def post(self, request):
ser = WecomBindingRequestSerializer(data=request.data)
ser.is_valid(raise_exception=True)
wecom_user_id = ser.validated_data['wecom_user_id']
username = ser.validated_data['username']
password = ser.validated_data['password']
force_update = ser.validated_data.get('force_update', False)
# 1. 验证本系统用户凭据
user = authenticate(username=username, password=password)
if user is None:
return Response(
{'error': '用户名或密码错误'},
status=status.HTTP_401_UNAUTHORIZED,
)
if not user.is_active:
return Response(
{'error': '该用户已被禁用'},
status=status.HTTP_403_FORBIDDEN,
)
# 2. 查找关联的 Employee
employee = Employee.objects.filter(sys_user=user).first()
if employee is None:
return Response(
{'error': '该用户未关联任何员工记录'},
status=status.HTTP_404_NOT_FOUND,
)
# 3. 检查该 wecom_user_id 是否已被其他 Employee 绑定
existing = Employee.objects.filter(wecom_user_id=wecom_user_id).exclude(id=employee.id).first()
if existing is not None:
return Response(
{'error': f'该企业微信用户已绑定到其他员工: {existing.name} (ID: {existing.id})'},
status=status.HTTP_409_CONFLICT,
)
# 4. 检查该 Employee 是否已有不同的 wecom_user_id
if employee.wecom_user_id and employee.wecom_user_id != wecom_user_id:
if not force_update:
return Response(
{
'error': '该员工已绑定其他企业微信用户',
'current_wecom_user_id': employee.wecom_user_id,
'hint': '如需覆盖,请传入 force_update=true',
},
status=status.HTTP_409_CONFLICT,
)
# 5. 执行绑定
employee.wecom_user_id = wecom_user_id
employee.save(update_fields=['wecom_user_id', 'updated_at'])
logger.info(
'WeChat Work binding success: wecom_user_id=%s -> employee=%s (ID: %s)',
wecom_user_id,
employee.name,
employee.id,
)
return Response(
{
'message': '绑定成功',
'employee_id': employee.id,
'employee_name': employee.name,
'wecom_user_id': wecom_user_id,
},
status=status.HTTP_200_OK,
)
class WecomLoginRequestSerializer(serializers.Serializer):
wecom_user_id = serializers.CharField(
max_length=128,
help_text='企业微信 OAuth2 返回的 user_id',
)
class WecomLoginView(APIView):
"""
企业微信快捷登录接口。
POST /api/v2/wecom/login/
通过已绑定的 wecom_user_id 获取本系统的 JWT token
无需再次输入用户名密码。
请求体:
- wecom_user_id: 企业微信用户 ID必须已通过绑定接口完成绑定
认证Authorization 头传入 AGENT_ACCESS_KEY
"""
authentication_classes = [AgentAccessKeyAuthentication]
permission_classes = [IsAuthenticated]
def post(self, request):
ser = WecomLoginRequestSerializer(data=request.data)
ser.is_valid(raise_exception=True)
wecom_user_id = ser.validated_data['wecom_user_id']
# 1. 通过 wecom_user_id 查找 Employee
employee = Employee.objects.select_related('sys_user').filter(
wecom_user_id=wecom_user_id,
).first()
if employee is None:
return Response(
{'error': '该企业微信用户未绑定本系统员工'},
status=status.HTTP_404_NOT_FOUND,
)
# 2. 检查关联的系统用户
user = employee.sys_user
if user is None:
return Response(
{'error': '该员工未关联系统用户'},
status=status.HTTP_404_NOT_FOUND,
)
if not user.is_active:
return Response(
{'error': '该用户已被禁用'},
status=status.HTTP_403_FORBIDDEN,
)
# 3. 生成 JWT token
refresh = RefreshToken.for_user(user)
logger.info(
'WeChat Work login success: wecom_user_id=%s -> user=%s (employee=%s)',
wecom_user_id,
user.username,
employee.name,
)
return Response(
{
'access': str(refresh.access_token),
'refresh': str(refresh),
'employee_id': employee.id,
'employee_name': employee.name,
'username': user.username,
},
status=status.HTTP_200_OK,
)