forked from erp-dev/erp
feat: error_code + api doc + printing-job fields
This commit is contained in:
22
api_v1/management/commands/set_app_version.py
Normal file
22
api_v1/management/commands/set_app_version.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from flower.app_version import set_cached_app_version_payload
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "更新 App 最新版本信息缓存"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--major", type=int, required=True)
|
||||||
|
parser.add_argument("--minor", type=int, required=True)
|
||||||
|
parser.add_argument("--build", type=int, required=True)
|
||||||
|
parser.add_argument("--download-url", required=True)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
payload = set_cached_app_version_payload(
|
||||||
|
major=options["major"],
|
||||||
|
minor=options["minor"],
|
||||||
|
build=options["build"],
|
||||||
|
download_url=options["download_url"],
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS(str(payload)))
|
||||||
@@ -8,7 +8,15 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument('--limit', type=int, default=100)
|
parser.add_argument('--limit', type=int, default=100)
|
||||||
|
parser.add_argument(
|
||||||
|
'--no-advance-cursor',
|
||||||
|
action='store_true',
|
||||||
|
help='只拉取并同步本批 records,不推进外部 cursor。适合本地/测试环境检查。',
|
||||||
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
payload = sync_external_printing_records.run(limit=options['limit'])
|
payload = sync_external_printing_records.run(
|
||||||
|
limit=options['limit'],
|
||||||
|
advance_cursor=not options['no_advance_cursor'],
|
||||||
|
)
|
||||||
self.stdout.write(self.style.SUCCESS(str(payload)))
|
self.stdout.write(self.style.SUCCESS(str(payload)))
|
||||||
|
|||||||
@@ -743,6 +743,10 @@ def _upload_product_image(product, image_payload: dict):
|
|||||||
product.save(update_fields=['image', 'updated_at'])
|
product.save(update_fields=['image', 'updated_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def _external_product_allows_missing_image(external_product_name: str) -> bool:
|
||||||
|
return '白布' in str(external_product_name or '').strip()
|
||||||
|
|
||||||
|
|
||||||
@shared_task(bind=True)
|
@shared_task(bind=True)
|
||||||
def backfill_external_product_images(
|
def backfill_external_product_images(
|
||||||
self,
|
self,
|
||||||
@@ -811,23 +815,38 @@ def _ensure_external_product(*, merchant, external_product_name: str, category):
|
|||||||
if not normalized:
|
if not normalized:
|
||||||
raise RuntimeError('外部产品名为空,无法创建/绑定产品')
|
raise RuntimeError('外部产品名为空,无法创建/绑定产品')
|
||||||
|
|
||||||
|
allows_missing_image = _external_product_allows_missing_image(normalized)
|
||||||
existing_product = (
|
existing_product = (
|
||||||
basic_models.Product.objects.filter(merchant=merchant, name=normalized)
|
basic_models.Product.objects.filter(merchant=merchant, name=normalized)
|
||||||
.order_by('id')
|
.order_by('id')
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if existing_product:
|
if existing_product:
|
||||||
|
if existing_product.image:
|
||||||
|
return existing_product, False
|
||||||
|
if allows_missing_image:
|
||||||
|
return existing_product, False
|
||||||
|
image_payload = _fetch_external_product_image(normalized)
|
||||||
|
_upload_product_image(existing_product, image_payload)
|
||||||
return existing_product, False
|
return existing_product, False
|
||||||
|
|
||||||
image_payload = _fetch_external_product_image(normalized)
|
|
||||||
product = basic_models.Product.objects.create(
|
product = basic_models.Product.objects.create(
|
||||||
merchant=merchant,
|
merchant=merchant,
|
||||||
category=category,
|
category=category,
|
||||||
name=normalized,
|
name=normalized,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
image_payload = _fetch_external_product_image(normalized)
|
||||||
_upload_product_image(product, image_payload)
|
_upload_product_image(product, image_payload)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
if allows_missing_image:
|
||||||
|
logger.warning(
|
||||||
|
'外部白布产品图片缺失,已创建无图 Product: product_id=%s product_name=%s error=%s',
|
||||||
|
product.id,
|
||||||
|
normalized,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return product, True
|
||||||
if product.image:
|
if product.image:
|
||||||
try:
|
try:
|
||||||
product.image.delete(save=False)
|
product.image.delete(save=False)
|
||||||
@@ -1876,14 +1895,14 @@ def sync_mdy_plate_orders_for_merchant(
|
|||||||
|
|
||||||
|
|
||||||
@shared_task(bind=True)
|
@shared_task(bind=True)
|
||||||
def sync_external_printing_records(self, limit: int = 100):
|
def sync_external_printing_records(self, limit: int = 100, advance_cursor: bool = True):
|
||||||
"""
|
"""
|
||||||
从外部 records 接口增量同步印染订单/任务。
|
从外部 records 接口增量同步印染订单/任务。
|
||||||
|
|
||||||
策略:
|
策略:
|
||||||
- 拉取时强制 update_cursor=false
|
- 拉取时强制 update_cursor=false
|
||||||
- 按外部 record.ID 处理失败并记录
|
- 按外部 record.ID 处理失败并记录
|
||||||
- 本批处理结束后无论是否有部分失败,都推进远端 cursor
|
- 默认在本批处理结束后推进远端 cursor;本地检查可传 advance_cursor=False 禁止推进
|
||||||
"""
|
"""
|
||||||
limit = max(1, int(limit or 100))
|
limit = max(1, int(limit or 100))
|
||||||
sync_user = _get_printing_sync_user()
|
sync_user = _get_printing_sync_user()
|
||||||
@@ -1908,7 +1927,7 @@ def sync_external_printing_records(self, limit: int = 100):
|
|||||||
)
|
)
|
||||||
|
|
||||||
cursor_payload = None
|
cursor_payload = None
|
||||||
if last_record_id is not None:
|
if advance_cursor and last_record_id is not None:
|
||||||
cursor_payload = _advance_external_printing_cursor(cursor_value=int(last_record_id))
|
cursor_payload = _advance_external_printing_cursor(cursor_value=int(last_record_id))
|
||||||
else:
|
else:
|
||||||
batch_result = {
|
batch_result = {
|
||||||
@@ -1947,6 +1966,7 @@ def sync_external_printing_records(self, limit: int = 100):
|
|||||||
'cursor_before': payload.get('cursor_before'),
|
'cursor_before': payload.get('cursor_before'),
|
||||||
'cursor_after': payload.get('cursor_after'),
|
'cursor_after': payload.get('cursor_after'),
|
||||||
'cursor_updated': bool(cursor_payload),
|
'cursor_updated': bool(cursor_payload),
|
||||||
|
'advance_cursor': bool(advance_cursor),
|
||||||
'group_count': batch_result['group_count'],
|
'group_count': batch_result['group_count'],
|
||||||
}
|
}
|
||||||
logger.info('外部印染 records 同步完成: %s', result)
|
logger.info('外部印染 records 同步完成: %s', result)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
category=self.category,
|
category=self.category,
|
||||||
name='Tj1712#12号色-24码',
|
name='Tj1712#12号色-24码',
|
||||||
|
image='product_images/existing.jpg',
|
||||||
)
|
)
|
||||||
|
|
||||||
state = State.objects.create(name='待生产')
|
state = State.objects.create(name='待生产')
|
||||||
@@ -232,6 +233,85 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
|
|||||||
self.assertEqual(failure.external_order_id, 'KD20432358')
|
self.assertEqual(failure.external_order_id, 'KD20432358')
|
||||||
self.assertIn('外部图片不存在', failure.error)
|
self.assertIn('外部图片不存在', failure.error)
|
||||||
|
|
||||||
|
@patch('api_v1.tasks._advance_external_printing_cursor')
|
||||||
|
@patch('api_v1.tasks._fetch_external_product_image')
|
||||||
|
@patch('api_v1.tasks._fetch_external_printing_records')
|
||||||
|
def test_sync_can_skip_advancing_external_cursor(
|
||||||
|
self,
|
||||||
|
mock_fetch_records,
|
||||||
|
mock_fetch_image,
|
||||||
|
mock_advance_cursor,
|
||||||
|
):
|
||||||
|
mock_fetch_records.return_value = self._build_payload(
|
||||||
|
[self._build_record(record_id=1000100, product_name=self.existing_product.name)]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = sync_external_printing_records.run(limit=100, advance_cursor=False)
|
||||||
|
|
||||||
|
self.assertEqual(result['jobs_created'], 1)
|
||||||
|
self.assertEqual(result['failed_records'], 0)
|
||||||
|
self.assertFalse(result['cursor_updated'])
|
||||||
|
self.assertFalse(result['advance_cursor'])
|
||||||
|
mock_advance_cursor.assert_not_called()
|
||||||
|
|
||||||
|
@patch('api_v1.tasks._advance_external_printing_cursor')
|
||||||
|
@patch('api_v1.tasks._fetch_external_product_image')
|
||||||
|
@patch('api_v1.tasks._fetch_external_printing_records')
|
||||||
|
def test_missing_white_fabric_image_does_not_block_sync_or_record_failure(
|
||||||
|
self,
|
||||||
|
mock_fetch_records,
|
||||||
|
mock_fetch_image,
|
||||||
|
mock_advance_cursor,
|
||||||
|
):
|
||||||
|
product_name = '加厚白布-25码'
|
||||||
|
mock_fetch_records.return_value = self._build_payload(
|
||||||
|
[self._build_record(record_id=1000101, product_name=product_name)]
|
||||||
|
)
|
||||||
|
mock_advance_cursor.return_value = {'updated': True}
|
||||||
|
mock_fetch_image.side_effect = RuntimeError(f'外部图片不存在: {product_name}')
|
||||||
|
|
||||||
|
result = sync_external_printing_records.run(limit=100)
|
||||||
|
|
||||||
|
self.assertEqual(result['jobs_created'], 1)
|
||||||
|
self.assertEqual(result['failed_records'], 0)
|
||||||
|
self.assertFalse(api_models.PrintingExternalSyncFailure.objects.exists())
|
||||||
|
|
||||||
|
product = basic_models.Product.objects.get(merchant=self.merchant, name=product_name)
|
||||||
|
self.assertFalse(product.image)
|
||||||
|
job = printing_models.PrintingJob.objects.get(original_id=1000101)
|
||||||
|
self.assertEqual(job.product, product)
|
||||||
|
|
||||||
|
@patch('api_v1.tasks._advance_external_printing_cursor')
|
||||||
|
@patch('api_v1.tasks._fetch_external_product_image')
|
||||||
|
@patch('api_v1.tasks._fetch_external_printing_records')
|
||||||
|
def test_existing_non_white_product_without_image_still_records_failure_when_image_missing(
|
||||||
|
self,
|
||||||
|
mock_fetch_records,
|
||||||
|
mock_fetch_image,
|
||||||
|
mock_advance_cursor,
|
||||||
|
):
|
||||||
|
product = basic_models.Product.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=self.category,
|
||||||
|
name='Tj1712#12号色-无图',
|
||||||
|
)
|
||||||
|
mock_fetch_records.return_value = self._build_payload(
|
||||||
|
[self._build_record(record_id=1000102, product_name=product.name)]
|
||||||
|
)
|
||||||
|
mock_advance_cursor.return_value = {'updated': True}
|
||||||
|
mock_fetch_image.side_effect = RuntimeError(f'外部图片不存在: {product.name}')
|
||||||
|
|
||||||
|
result = sync_external_printing_records.run(limit=100)
|
||||||
|
|
||||||
|
self.assertEqual(result['jobs_created'], 0)
|
||||||
|
self.assertEqual(result['failed_records'], 1)
|
||||||
|
self.assertEqual(result['failed_record_ids'], [1000102])
|
||||||
|
self.assertFalse(printing_models.PrintingJob.objects.filter(original_id=1000102).exists())
|
||||||
|
|
||||||
|
failure = api_models.PrintingExternalSyncFailure.objects.get(external_record_id=1000102)
|
||||||
|
self.assertEqual(failure.product_name, product.name)
|
||||||
|
self.assertIn('外部图片不存在', failure.error)
|
||||||
|
|
||||||
@patch('api_v1.tasks._advance_external_printing_cursor')
|
@patch('api_v1.tasks._advance_external_printing_cursor')
|
||||||
@patch('api_v1.tasks._fetch_external_product_image')
|
@patch('api_v1.tasks._fetch_external_product_image')
|
||||||
@patch('api_v1.tasks._fetch_external_printing_records')
|
@patch('api_v1.tasks._fetch_external_printing_records')
|
||||||
@@ -324,6 +404,16 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
|
|||||||
self.assertIn("'remaining_failures': 0", output.getvalue())
|
self.assertIn("'remaining_failures': 0", output.getvalue())
|
||||||
self.assertFalse(api_models.PrintingExternalSyncFailure.objects.exists())
|
self.assertFalse(api_models.PrintingExternalSyncFailure.objects.exists())
|
||||||
|
|
||||||
|
@patch('api_v1.management.commands.sync_external_printing_records.sync_external_printing_records')
|
||||||
|
def test_sync_command_supports_no_advance_cursor(self, mock_task):
|
||||||
|
mock_task.run.return_value = {'cursor_updated': False, 'advance_cursor': False}
|
||||||
|
|
||||||
|
output = StringIO()
|
||||||
|
call_command('sync_external_printing_records', '--limit', '5', '--no-advance-cursor', stdout=output)
|
||||||
|
|
||||||
|
mock_task.run.assert_called_once_with(limit=5, advance_cursor=False)
|
||||||
|
self.assertIn("'advance_cursor': False", output.getvalue())
|
||||||
|
|
||||||
@patch('api_v1.tasks._advance_external_printing_cursor')
|
@patch('api_v1.tasks._advance_external_printing_cursor')
|
||||||
@patch('api_v1.tasks._fetch_external_product_image')
|
@patch('api_v1.tasks._fetch_external_product_image')
|
||||||
@patch('api_v1.tasks._fetch_external_printing_records')
|
@patch('api_v1.tasks._fetch_external_printing_records')
|
||||||
|
|||||||
@@ -385,6 +385,8 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
|||||||
billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True)
|
billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True)
|
||||||
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
|
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
|
||||||
fabric = serializers.SerializerMethodField()
|
fabric = serializers.SerializerMethodField()
|
||||||
|
curve = serializers.CharField(source='printing_order.curve', read_only=True, allow_null=True)
|
||||||
|
new_curve = serializers.CharField(source='printing_order.new_curve', read_only=True, allow_null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PrintingJob
|
model = models.PrintingJob
|
||||||
@@ -400,6 +402,8 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
|||||||
'batch_advance_records',
|
'batch_advance_records',
|
||||||
'saleitems',
|
'saleitems',
|
||||||
'fabric',
|
'fabric',
|
||||||
|
'curve',
|
||||||
|
'new_curve',
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
@@ -497,6 +501,8 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
|||||||
billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True)
|
billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True)
|
||||||
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
|
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
|
||||||
fabric = serializers.SerializerMethodField()
|
fabric = serializers.SerializerMethodField()
|
||||||
|
curve = serializers.CharField(source='printing_order.curve', read_only=True, allow_null=True)
|
||||||
|
new_curve = serializers.CharField(source='printing_order.new_curve', read_only=True, allow_null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PrintingJob
|
model = models.PrintingJob
|
||||||
@@ -511,6 +517,8 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
|||||||
'batch_advance_records',
|
'batch_advance_records',
|
||||||
'saleitems',
|
'saleitems',
|
||||||
'fabric',
|
'fabric',
|
||||||
|
'curve',
|
||||||
|
'new_curve',
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
fabric='测试布料',
|
fabric='测试布料',
|
||||||
width='150cm',
|
width='150cm',
|
||||||
|
curve='测试曲线',
|
||||||
|
new_curve='测试新曲线',
|
||||||
process=self.process,
|
process=self.process,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -191,6 +193,8 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
for item in response.data['results']:
|
for item in response.data['results']:
|
||||||
self.assertIn('customer_name', item)
|
self.assertIn('customer_name', item)
|
||||||
self.assertEqual(item['customer_name'], self.customer.name)
|
self.assertEqual(item['customer_name'], self.customer.name)
|
||||||
|
self.assertEqual(item['curve'], '测试曲线')
|
||||||
|
self.assertEqual(item['new_curve'], '测试新曲线')
|
||||||
self.assertIn('batch_advance_records', item)
|
self.assertIn('batch_advance_records', item)
|
||||||
self.assertIsInstance(item['batch_advance_records'], list)
|
self.assertIsInstance(item['batch_advance_records'], list)
|
||||||
self.assertEqual(len(item['batch_advance_records']), 0)
|
self.assertEqual(len(item['batch_advance_records']), 0)
|
||||||
@@ -944,6 +948,8 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
detail = self.client.get(f'/api/v1/printing-jobs/{job.id}/')
|
detail = self.client.get(f'/api/v1/printing-jobs/{job.id}/')
|
||||||
self.assertEqual(detail.status_code, status.HTTP_200_OK)
|
self.assertEqual(detail.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(detail.data['external_order_id'], 'KD20410611')
|
self.assertEqual(detail.data['external_order_id'], 'KD20410611')
|
||||||
|
self.assertEqual(detail.data['curve'], '测试曲线')
|
||||||
|
self.assertEqual(detail.data['new_curve'], '测试新曲线')
|
||||||
|
|
||||||
def test_filter_by_quantity_range(self):
|
def test_filter_by_quantity_range(self):
|
||||||
"""测试按数量范围过滤"""
|
"""测试按数量范围过滤"""
|
||||||
|
|||||||
264
api_v2/test_auth_login_api.py
Normal file
264
api_v2/test_auth_login_api.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from basic_info.models import Employee, EmployeeStatusEnum, Merchant, MerchantTypeEnum
|
||||||
|
from flower.error_code import AuthErrorCode
|
||||||
|
|
||||||
|
|
||||||
|
class AuthLoginAPITestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.url = '/api/auth/login/'
|
||||||
|
self.client = APIClient()
|
||||||
|
self.merchant = Merchant.objects.create(name='登录商户', type=MerchantTypeEnum.FACTORY)
|
||||||
|
|
||||||
|
def _create_user_with_employee(self, *, username='login_user', password='pass12345', is_active=True,
|
||||||
|
employee_status=EmployeeStatusEnum.ACTIVE):
|
||||||
|
user = User.objects.create_user(username=username, password=password, is_active=is_active)
|
||||||
|
Employee.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
sys_user=user,
|
||||||
|
name=f'{username}员工',
|
||||||
|
status=employee_status,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def assert_login_error(self, response, *, http_status, error_code, code, message):
|
||||||
|
self.assertEqual(response.status_code, http_status)
|
||||||
|
self.assertEqual(response.data['error_code'], int(error_code))
|
||||||
|
self.assertEqual(response.data['code'], code)
|
||||||
|
self.assertEqual(response.data['message'], message)
|
||||||
|
self.assertEqual(response.data['detail'], message)
|
||||||
|
|
||||||
|
def test_login_success(self):
|
||||||
|
self._create_user_with_employee(username='success_user', password='pass12345')
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{'username': 'success_user', 'password': 'pass12345'},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn('access', response.data)
|
||||||
|
self.assertIn('refresh', response.data)
|
||||||
|
|
||||||
|
def test_login_requires_username_and_password(self):
|
||||||
|
response = self.client.post(self.url, {}, format='json')
|
||||||
|
|
||||||
|
self.assert_login_error(
|
||||||
|
response,
|
||||||
|
http_status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
error_code=AuthErrorCode.MISSING_CREDENTIALS,
|
||||||
|
code='missing_credentials',
|
||||||
|
message='请输入用户名和密码',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_requires_username(self):
|
||||||
|
response = self.client.post(self.url, {'password': 'pass12345'}, format='json')
|
||||||
|
|
||||||
|
self.assert_login_error(
|
||||||
|
response,
|
||||||
|
http_status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
error_code=AuthErrorCode.MISSING_USERNAME,
|
||||||
|
code='missing_username',
|
||||||
|
message='请输入用户名',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_requires_password(self):
|
||||||
|
response = self.client.post(self.url, {'username': 'login_user'}, format='json')
|
||||||
|
|
||||||
|
self.assert_login_error(
|
||||||
|
response,
|
||||||
|
http_status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
error_code=AuthErrorCode.MISSING_PASSWORD,
|
||||||
|
code='missing_password',
|
||||||
|
message='请输入密码',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_rejects_unknown_user(self):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{'username': 'missing_user', 'password': 'pass12345'},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_login_error(
|
||||||
|
response,
|
||||||
|
http_status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
error_code=AuthErrorCode.USER_NOT_FOUND,
|
||||||
|
code='user_not_found',
|
||||||
|
message='用户不存在',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_rejects_invalid_password(self):
|
||||||
|
self._create_user_with_employee(username='wrong_password_user', password='pass12345')
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{'username': 'wrong_password_user', 'password': 'bad-password'},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_login_error(
|
||||||
|
response,
|
||||||
|
http_status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
error_code=AuthErrorCode.INVALID_PASSWORD,
|
||||||
|
code='invalid_password',
|
||||||
|
message='密码错误',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_rejects_inactive_user(self):
|
||||||
|
self._create_user_with_employee(username='inactive_user', password='pass12345', is_active=False)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{'username': 'inactive_user', 'password': 'pass12345'},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_login_error(
|
||||||
|
response,
|
||||||
|
http_status=status.HTTP_403_FORBIDDEN,
|
||||||
|
error_code=AuthErrorCode.USER_INACTIVE,
|
||||||
|
code='user_inactive',
|
||||||
|
message='该用户已被禁用',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_rejects_user_without_employee(self):
|
||||||
|
User.objects.create_user(username='no_employee_user', password='pass12345')
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{'username': 'no_employee_user', 'password': 'pass12345'},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_login_error(
|
||||||
|
response,
|
||||||
|
http_status=status.HTTP_403_FORBIDDEN,
|
||||||
|
error_code=AuthErrorCode.EMPLOYEE_NOT_BOUND,
|
||||||
|
code='employee_not_bound',
|
||||||
|
message='该用户未绑定员工身份',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_rejects_inactive_employee(self):
|
||||||
|
self._create_user_with_employee(
|
||||||
|
username='inactive_employee_user',
|
||||||
|
password='pass12345',
|
||||||
|
employee_status=EmployeeStatusEnum.INACTIVE,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{'username': 'inactive_employee_user', 'password': 'pass12345'},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_login_error(
|
||||||
|
response,
|
||||||
|
http_status=status.HTTP_403_FORBIDDEN,
|
||||||
|
error_code=AuthErrorCode.EMPLOYEE_INACTIVE,
|
||||||
|
code='employee_inactive',
|
||||||
|
message='该员工已离职或停用',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JwtAuthenticationFailureAPITestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.url = '/api/v2/me/visible-pages/'
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def assert_auth_error(self, response, *, http_status, error_code, code, message):
|
||||||
|
self.assertEqual(response.status_code, http_status)
|
||||||
|
self.assertEqual(response.data['error_code'], int(error_code))
|
||||||
|
self.assertEqual(response.data['code'], code)
|
||||||
|
self.assertEqual(response.data['message'], message)
|
||||||
|
self.assertEqual(response.data['detail'], message)
|
||||||
|
|
||||||
|
def test_protected_api_without_token_returns_structured_error(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
self.assert_auth_error(
|
||||||
|
response,
|
||||||
|
http_status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
error_code=AuthErrorCode.NOT_AUTHENTICATED,
|
||||||
|
code='not_authenticated',
|
||||||
|
message='未提供认证凭据',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_protected_api_bad_authorization_header_returns_structured_error(self):
|
||||||
|
response = self.client.get(self.url, HTTP_AUTHORIZATION='Bearer token extra')
|
||||||
|
|
||||||
|
self.assert_auth_error(
|
||||||
|
response,
|
||||||
|
http_status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
error_code=AuthErrorCode.BAD_AUTHORIZATION_HEADER,
|
||||||
|
code='bad_authorization_header',
|
||||||
|
message='Authorization 请求头格式错误',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_protected_api_invalid_token_returns_structured_error(self):
|
||||||
|
response = self.client.get(self.url, HTTP_AUTHORIZATION='Bearer invalid-token')
|
||||||
|
|
||||||
|
self.assert_auth_error(
|
||||||
|
response,
|
||||||
|
http_status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
error_code=AuthErrorCode.TOKEN_NOT_VALID,
|
||||||
|
code='token_not_valid',
|
||||||
|
message='Token 无效或已过期',
|
||||||
|
)
|
||||||
|
self.assertIn('messages', response.data)
|
||||||
|
|
||||||
|
def test_protected_api_deleted_token_user_returns_structured_error(self):
|
||||||
|
user = User.objects.create_user(username='deleted_token_user', password='pass12345')
|
||||||
|
token = str(RefreshToken.for_user(user).access_token)
|
||||||
|
user.delete()
|
||||||
|
|
||||||
|
response = self.client.get(self.url, HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||||
|
|
||||||
|
self.assert_auth_error(
|
||||||
|
response,
|
||||||
|
http_status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
error_code=AuthErrorCode.TOKEN_USER_NOT_FOUND,
|
||||||
|
code='token_user_not_found',
|
||||||
|
message='Token 对应用户不存在',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_protected_api_inactive_token_user_returns_structured_error(self):
|
||||||
|
user = User.objects.create_user(username='inactive_token_user', password='pass12345', is_active=False)
|
||||||
|
token = str(RefreshToken.for_user(user).access_token)
|
||||||
|
|
||||||
|
response = self.client.get(self.url, HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||||
|
|
||||||
|
self.assert_auth_error(
|
||||||
|
response,
|
||||||
|
http_status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
error_code=AuthErrorCode.TOKEN_USER_INACTIVE,
|
||||||
|
code='token_user_inactive',
|
||||||
|
message='Token 对应用户已被禁用',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorCodeListAPITestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.url = '/api/error-codes/'
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def test_error_code_list_is_public_and_includes_auth_codes(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
auth_group = next(group for group in response.data['modules'] if group['module'] == 'auth')
|
||||||
|
self.assertEqual(auth_group['title'], '认证')
|
||||||
|
|
||||||
|
codes = {item['error_code']: item for item in auth_group['codes']}
|
||||||
|
self.assertEqual(codes[10001]['name'], 'MISSING_CREDENTIALS')
|
||||||
|
self.assertEqual(codes[10001]['code'], 'missing_credentials')
|
||||||
|
self.assertEqual(codes[10001]['message'], '请输入用户名和密码')
|
||||||
|
self.assertEqual(codes[10014]['name'], 'PERMISSION_DENIED')
|
||||||
|
self.assertEqual(codes[10014]['code'], 'permission_denied')
|
||||||
|
self.assertEqual(codes[10014]['message'], '无权限访问该资源')
|
||||||
|
self.assertEqual(set(codes.keys()), {int(item) for item in AuthErrorCode})
|
||||||
@@ -46,11 +46,13 @@ class PrintingOrderExternalSnapshotSyncAPITest(TestCase):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
category=self.category,
|
category=self.category,
|
||||||
name='Tj1712#12号色-24码',
|
name='Tj1712#12号色-24码',
|
||||||
|
image='product_images/keep.jpg',
|
||||||
)
|
)
|
||||||
self.product_stale = basic_models.Product.objects.create(
|
self.product_stale = basic_models.Product.objects.create(
|
||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
category=self.category,
|
category=self.category,
|
||||||
name='Tj1712#12号色-25码',
|
name='Tj1712#12号色-25码',
|
||||||
|
image='product_images/stale.jpg',
|
||||||
)
|
)
|
||||||
|
|
||||||
state = stateflow_models.State.objects.create(name='待生产')
|
state = stateflow_models.State.objects.create(name='待生产')
|
||||||
@@ -233,6 +235,39 @@ class PrintingOrderExternalSnapshotSyncAPITest(TestCase):
|
|||||||
self.assertEqual(len(audit.before_snapshot['printing_jobs']), 2)
|
self.assertEqual(len(audit.before_snapshot['printing_jobs']), 2)
|
||||||
self.assertIn('sub_id', audit.before_snapshot['printing_jobs'][0])
|
self.assertIn('sub_id', audit.before_snapshot['printing_jobs'][0])
|
||||||
|
|
||||||
|
@patch('api_v1.tasks._fetch_external_product_image')
|
||||||
|
@patch('api_v1.tasks._fetch_external_printing_order_snapshot')
|
||||||
|
def test_sync_allows_missing_image_for_white_fabric_product(self, mock_fetch_snapshot, mock_fetch_image):
|
||||||
|
product_name = '外部白布-快照款'
|
||||||
|
mock_fetch_snapshot.return_value = self._build_snapshot_payload(
|
||||||
|
records=[
|
||||||
|
self._build_record(
|
||||||
|
record_id=1000018,
|
||||||
|
product_name=product_name,
|
||||||
|
quantity='12.00',
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
mock_fetch_image.side_effect = RuntimeError(f'外部图片不存在: {product_name}')
|
||||||
|
|
||||||
|
resp = self.client.post(
|
||||||
|
'/api/v2/printing-orders/sync-external-snapshot/',
|
||||||
|
{'external_order_id': 'KD20432358'},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(resp.data['jobs_created'], 1)
|
||||||
|
|
||||||
|
product = basic_models.Product.objects.get(merchant=self.merchant, name=product_name)
|
||||||
|
self.assertFalse(product.image)
|
||||||
|
job = printing_models.PrintingJob.objects.get(original_id=1000018)
|
||||||
|
self.assertEqual(job.product, product)
|
||||||
|
|
||||||
|
audit = printing_models.PrintingOrderExternalSnapshotSyncAudit.objects.get(id=resp.data['audit_id'])
|
||||||
|
self.assertTrue(audit.is_success)
|
||||||
|
self.assertEqual(audit.failure_reason, '')
|
||||||
|
|
||||||
@patch('api_v1.tasks._fetch_external_printing_order_snapshot')
|
@patch('api_v1.tasks._fetch_external_printing_order_snapshot')
|
||||||
def test_sync_records_audit_when_external_snapshot_not_found(self, mock_fetch_snapshot):
|
def test_sync_records_audit_when_external_snapshot_not_found(self, mock_fetch_snapshot):
|
||||||
mock_fetch_snapshot.side_effect = ExternalPrintingOrderSnapshotNotFoundError('未找到该 external_order_id 对应的订单')
|
mock_fetch_snapshot.side_effect = ExternalPrintingOrderSnapshotNotFoundError('未找到该 external_order_id 对应的订单')
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ class PrintingJobByCustomerAPITest(TestCase):
|
|||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
fabric='棉',
|
fabric='棉',
|
||||||
width='150cm',
|
width='150cm',
|
||||||
|
curve='曲线A',
|
||||||
|
new_curve='新曲线A',
|
||||||
external_order_id='KD20410611',
|
external_order_id='KD20410611',
|
||||||
)
|
)
|
||||||
self.printing_order_other = printing_models.PrintingOrder.objects.create(
|
self.printing_order_other = printing_models.PrintingOrder.objects.create(
|
||||||
@@ -256,6 +258,8 @@ class PrintingJobByCustomerAPITest(TestCase):
|
|||||||
self.assertEqual(resp.data[0]['external_order_id'], 'KD20410611')
|
self.assertEqual(resp.data[0]['external_order_id'], 'KD20410611')
|
||||||
self.assertEqual(resp.data[0]['sub_id'], 11)
|
self.assertEqual(resp.data[0]['sub_id'], 11)
|
||||||
self.assertEqual(resp.data[0]['billed_quantity'], '20.00')
|
self.assertEqual(resp.data[0]['billed_quantity'], '20.00')
|
||||||
|
self.assertEqual(resp.data[0]['curve'], '曲线A')
|
||||||
|
self.assertEqual(resp.data[0]['new_curve'], '新曲线A')
|
||||||
|
|
||||||
def test_filter_by_printing_order(self):
|
def test_filter_by_printing_order(self):
|
||||||
resp = self._get({
|
resp = self._get({
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ class PrintingJobV2Serializer(serializers.ModelSerializer):
|
|||||||
business_object_id = serializers.SerializerMethodField()
|
business_object_id = serializers.SerializerMethodField()
|
||||||
width = serializers.SerializerMethodField()
|
width = serializers.SerializerMethodField()
|
||||||
fabric = serializers.SerializerMethodField()
|
fabric = serializers.SerializerMethodField()
|
||||||
|
curve = serializers.CharField(source='printing_order.curve', read_only=True, allow_null=True)
|
||||||
|
new_curve = serializers.CharField(source='printing_order.new_curve', read_only=True, allow_null=True)
|
||||||
product = ProductSerializer(read_only=True)
|
product = ProductSerializer(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -70,6 +72,8 @@ class PrintingJobV2Serializer(serializers.ModelSerializer):
|
|||||||
'quantity',
|
'quantity',
|
||||||
'width',
|
'width',
|
||||||
'fabric',
|
'fabric',
|
||||||
|
'curve',
|
||||||
|
'new_curve',
|
||||||
'unit',
|
'unit',
|
||||||
'size',
|
'size',
|
||||||
'pieces',
|
'pieces',
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
63
flower/app_version.py
Normal file
63
flower/app_version.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
|
||||||
|
APP_VERSION_CACHE_KEY = "app_version:latest"
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_version_part(value) -> int:
|
||||||
|
return max(0, int(value or 0))
|
||||||
|
|
||||||
|
|
||||||
|
def build_app_version_payload(*, major, minor, build, download_url: str) -> dict:
|
||||||
|
return {
|
||||||
|
"latest_version": {
|
||||||
|
"major": _coerce_version_part(major),
|
||||||
|
"minor": _coerce_version_part(minor),
|
||||||
|
"build": _coerce_version_part(build),
|
||||||
|
},
|
||||||
|
"download_url": str(download_url or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_app_version_payload() -> dict:
|
||||||
|
cached_payload = cache.get(APP_VERSION_CACHE_KEY)
|
||||||
|
if isinstance(cached_payload, dict):
|
||||||
|
try:
|
||||||
|
version = cached_payload["latest_version"]
|
||||||
|
return build_app_version_payload(
|
||||||
|
major=version["major"],
|
||||||
|
minor=version["minor"],
|
||||||
|
build=version["build"],
|
||||||
|
download_url=cached_payload.get("download_url", ""),
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return build_app_version_payload(
|
||||||
|
major=getattr(settings, "APP_LATEST_VERSION_MAJOR", 0),
|
||||||
|
minor=getattr(settings, "APP_LATEST_VERSION_MINOR", 0),
|
||||||
|
build=getattr(settings, "APP_LATEST_VERSION_BUILD", 0),
|
||||||
|
download_url=getattr(settings, "APP_DOWNLOAD_URL", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_cached_app_version_payload(*, major, minor, build, download_url: str) -> dict:
|
||||||
|
payload = build_app_version_payload(
|
||||||
|
major=major,
|
||||||
|
minor=minor,
|
||||||
|
build=build,
|
||||||
|
download_url=download_url,
|
||||||
|
)
|
||||||
|
cache.set(APP_VERSION_CACHE_KEY, payload, timeout=None)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class AppVersionView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
return Response(get_app_version_payload())
|
||||||
88
flower/error_code.py
Normal file
88
flower/error_code.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
|
||||||
|
class AuthErrorCode(IntEnum):
|
||||||
|
MISSING_CREDENTIALS = 10001
|
||||||
|
MISSING_USERNAME = 10002
|
||||||
|
MISSING_PASSWORD = 10003
|
||||||
|
USER_NOT_FOUND = 10004
|
||||||
|
INVALID_PASSWORD = 10005
|
||||||
|
USER_INACTIVE = 10006
|
||||||
|
EMPLOYEE_NOT_BOUND = 10007
|
||||||
|
EMPLOYEE_INACTIVE = 10008
|
||||||
|
NOT_AUTHENTICATED = 10009
|
||||||
|
BAD_AUTHORIZATION_HEADER = 10010
|
||||||
|
TOKEN_NOT_VALID = 10011
|
||||||
|
TOKEN_USER_NOT_FOUND = 10012
|
||||||
|
TOKEN_USER_INACTIVE = 10013
|
||||||
|
PERMISSION_DENIED = 10014
|
||||||
|
|
||||||
|
|
||||||
|
AUTH_ERROR_CODE_DETAILS = {
|
||||||
|
AuthErrorCode.MISSING_CREDENTIALS: {
|
||||||
|
'code': 'missing_credentials',
|
||||||
|
'message': '请输入用户名和密码',
|
||||||
|
},
|
||||||
|
AuthErrorCode.MISSING_USERNAME: {
|
||||||
|
'code': 'missing_username',
|
||||||
|
'message': '请输入用户名',
|
||||||
|
},
|
||||||
|
AuthErrorCode.MISSING_PASSWORD: {
|
||||||
|
'code': 'missing_password',
|
||||||
|
'message': '请输入密码',
|
||||||
|
},
|
||||||
|
AuthErrorCode.USER_NOT_FOUND: {
|
||||||
|
'code': 'user_not_found',
|
||||||
|
'message': '用户不存在',
|
||||||
|
},
|
||||||
|
AuthErrorCode.INVALID_PASSWORD: {
|
||||||
|
'code': 'invalid_password',
|
||||||
|
'message': '密码错误',
|
||||||
|
},
|
||||||
|
AuthErrorCode.USER_INACTIVE: {
|
||||||
|
'code': 'user_inactive',
|
||||||
|
'message': '该用户已被禁用',
|
||||||
|
},
|
||||||
|
AuthErrorCode.EMPLOYEE_NOT_BOUND: {
|
||||||
|
'code': 'employee_not_bound',
|
||||||
|
'message': '该用户未绑定员工身份',
|
||||||
|
},
|
||||||
|
AuthErrorCode.EMPLOYEE_INACTIVE: {
|
||||||
|
'code': 'employee_inactive',
|
||||||
|
'message': '该员工已离职或停用',
|
||||||
|
},
|
||||||
|
AuthErrorCode.NOT_AUTHENTICATED: {
|
||||||
|
'code': 'not_authenticated',
|
||||||
|
'message': '未提供认证凭据',
|
||||||
|
},
|
||||||
|
AuthErrorCode.BAD_AUTHORIZATION_HEADER: {
|
||||||
|
'code': 'bad_authorization_header',
|
||||||
|
'message': 'Authorization 请求头格式错误',
|
||||||
|
},
|
||||||
|
AuthErrorCode.TOKEN_NOT_VALID: {
|
||||||
|
'code': 'token_not_valid',
|
||||||
|
'message': 'Token 无效或已过期',
|
||||||
|
},
|
||||||
|
AuthErrorCode.TOKEN_USER_NOT_FOUND: {
|
||||||
|
'code': 'token_user_not_found',
|
||||||
|
'message': 'Token 对应用户不存在',
|
||||||
|
},
|
||||||
|
AuthErrorCode.TOKEN_USER_INACTIVE: {
|
||||||
|
'code': 'token_user_inactive',
|
||||||
|
'message': 'Token 对应用户已被禁用',
|
||||||
|
},
|
||||||
|
AuthErrorCode.PERMISSION_DENIED: {
|
||||||
|
'code': 'permission_denied',
|
||||||
|
'message': '无权限访问该资源',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ERROR_CODE_GROUPS = [
|
||||||
|
{
|
||||||
|
'module': 'auth',
|
||||||
|
'title': '认证',
|
||||||
|
'enum': AuthErrorCode,
|
||||||
|
'details': AUTH_ERROR_CODE_DETAILS,
|
||||||
|
},
|
||||||
|
]
|
||||||
73
flower/error_code_registry.py
Normal file
73
flower/error_code_registry.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
|
||||||
|
|
||||||
|
_CACHE = None
|
||||||
|
_CACHE_LOCK = Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def list_error_code_groups():
|
||||||
|
global _CACHE
|
||||||
|
if _CACHE is None:
|
||||||
|
with _CACHE_LOCK:
|
||||||
|
if _CACHE is None:
|
||||||
|
_CACHE = _discover_error_code_groups()
|
||||||
|
return _CACHE
|
||||||
|
|
||||||
|
|
||||||
|
def clear_error_code_cache():
|
||||||
|
global _CACHE
|
||||||
|
with _CACHE_LOCK:
|
||||||
|
_CACHE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_error_code_groups():
|
||||||
|
modules = ['flower']
|
||||||
|
modules.extend(config.name for config in apps.get_app_configs())
|
||||||
|
|
||||||
|
groups = []
|
||||||
|
seen_modules = set()
|
||||||
|
for module_name in modules:
|
||||||
|
if module_name in seen_modules:
|
||||||
|
continue
|
||||||
|
seen_modules.add(module_name)
|
||||||
|
error_code_module = _import_error_code_module(module_name)
|
||||||
|
if error_code_module is None:
|
||||||
|
continue
|
||||||
|
for group in getattr(error_code_module, 'ERROR_CODE_GROUPS', []):
|
||||||
|
groups.append(_serialize_group(group))
|
||||||
|
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def _import_error_code_module(module_name: str):
|
||||||
|
error_code_module_name = f'{module_name}.error_code'
|
||||||
|
try:
|
||||||
|
return import_module(error_code_module_name)
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
if exc.name == error_code_module_name:
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_group(group: dict):
|
||||||
|
enum_cls = group['enum']
|
||||||
|
details = group.get('details') or {}
|
||||||
|
return {
|
||||||
|
'module': group['module'],
|
||||||
|
'title': group.get('title') or group['module'],
|
||||||
|
'codes': [_serialize_code(member, details.get(member, {})) for member in enum_cls],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_code(member, detail: dict):
|
||||||
|
return {
|
||||||
|
'error_code': int(member),
|
||||||
|
'name': member.name,
|
||||||
|
'code': detail.get('code', member.name.lower()),
|
||||||
|
'message': detail.get('message', ''),
|
||||||
|
}
|
||||||
12
flower/error_code_views.py
Normal file
12
flower/error_code_views.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from flower.error_code_registry import list_error_code_groups
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorCodeListView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
return Response({'modules': list_error_code_groups()})
|
||||||
101
flower/exception_handler.py
Normal file
101
flower/exception_handler.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from rest_framework import exceptions, status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import exception_handler
|
||||||
|
from rest_framework_simplejwt.exceptions import InvalidToken
|
||||||
|
|
||||||
|
from flower.error_code import AuthErrorCode
|
||||||
|
|
||||||
|
|
||||||
|
def auth_error_response(*, error_code: AuthErrorCode, code: str, message: str, http_status: int, extra=None):
|
||||||
|
data = {
|
||||||
|
'error_code': int(error_code),
|
||||||
|
'code': code,
|
||||||
|
'message': message,
|
||||||
|
'detail': message,
|
||||||
|
}
|
||||||
|
if extra:
|
||||||
|
data.update(extra)
|
||||||
|
return Response(data, status=http_status)
|
||||||
|
|
||||||
|
|
||||||
|
def _stringify_detail(detail):
|
||||||
|
if hasattr(detail, 'code'):
|
||||||
|
return str(detail), str(detail.code)
|
||||||
|
return str(detail), None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_exception_detail(exc):
|
||||||
|
detail = getattr(exc, 'detail', None)
|
||||||
|
if isinstance(detail, dict):
|
||||||
|
raw_detail = detail.get('detail')
|
||||||
|
raw_code = detail.get('code')
|
||||||
|
messages = detail.get('messages')
|
||||||
|
message = str(raw_detail) if raw_detail is not None else ''
|
||||||
|
code = str(raw_code) if raw_code is not None else None
|
||||||
|
extra = {'messages': messages} if messages is not None else None
|
||||||
|
return message, code, extra
|
||||||
|
|
||||||
|
message, code = _stringify_detail(detail)
|
||||||
|
return message, code, None
|
||||||
|
|
||||||
|
|
||||||
|
def custom_exception_handler(exc, context):
|
||||||
|
if isinstance(exc, InvalidToken):
|
||||||
|
_, _, extra = _extract_exception_detail(exc)
|
||||||
|
return auth_error_response(
|
||||||
|
error_code=AuthErrorCode.TOKEN_NOT_VALID,
|
||||||
|
code='token_not_valid',
|
||||||
|
message='Token 无效或已过期',
|
||||||
|
http_status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(exc, exceptions.NotAuthenticated):
|
||||||
|
return auth_error_response(
|
||||||
|
error_code=AuthErrorCode.NOT_AUTHENTICATED,
|
||||||
|
code='not_authenticated',
|
||||||
|
message='未提供认证凭据',
|
||||||
|
http_status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(exc, exceptions.AuthenticationFailed):
|
||||||
|
message, code, extra = _extract_exception_detail(exc)
|
||||||
|
if code == 'bad_authorization_header':
|
||||||
|
return auth_error_response(
|
||||||
|
error_code=AuthErrorCode.BAD_AUTHORIZATION_HEADER,
|
||||||
|
code='bad_authorization_header',
|
||||||
|
message='Authorization 请求头格式错误',
|
||||||
|
http_status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
if code == 'user_not_found':
|
||||||
|
return auth_error_response(
|
||||||
|
error_code=AuthErrorCode.TOKEN_USER_NOT_FOUND,
|
||||||
|
code='token_user_not_found',
|
||||||
|
message='Token 对应用户不存在',
|
||||||
|
http_status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
if code == 'user_inactive':
|
||||||
|
return auth_error_response(
|
||||||
|
error_code=AuthErrorCode.TOKEN_USER_INACTIVE,
|
||||||
|
code='token_user_inactive',
|
||||||
|
message='Token 对应用户已被禁用',
|
||||||
|
http_status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
return auth_error_response(
|
||||||
|
error_code=AuthErrorCode.TOKEN_NOT_VALID,
|
||||||
|
code=code or 'authentication_failed',
|
||||||
|
message=message or '认证失败',
|
||||||
|
http_status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(exc, exceptions.PermissionDenied):
|
||||||
|
return auth_error_response(
|
||||||
|
error_code=AuthErrorCode.PERMISSION_DENIED,
|
||||||
|
code='permission_denied',
|
||||||
|
message='无权限访问该资源',
|
||||||
|
http_status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
return exception_handler(exc, context)
|
||||||
@@ -111,6 +111,13 @@ HAOBUYE_FINANCE_SYNC_OPERATOR_ID = env.int('HAOBUYE_FINANCE_SYNC_OPERATOR_ID', d
|
|||||||
# - standard_decimal_2: 原系统口径,行金额=ROUND_HALF_UP(real_quantity * price, 2),收/付款金额保留两位
|
# - standard_decimal_2: 原系统口径,行金额=ROUND_HALF_UP(real_quantity * price, 2),收/付款金额保留两位
|
||||||
BUSINESS_AMOUNT_MODE = env('BUSINESS_AMOUNT_MODE', default='haobuye_integer_round_half_up')
|
BUSINESS_AMOUNT_MODE = env('BUSINESS_AMOUNT_MODE', default='haobuye_integer_round_half_up')
|
||||||
|
|
||||||
|
# App version check API
|
||||||
|
# 默认由环境变量提供;如需不重启动态更新,可用管理命令写入缓存。
|
||||||
|
APP_LATEST_VERSION_MAJOR = env.int('APP_LATEST_VERSION_MAJOR', default=0)
|
||||||
|
APP_LATEST_VERSION_MINOR = env.int('APP_LATEST_VERSION_MINOR', default=0)
|
||||||
|
APP_LATEST_VERSION_BUILD = env.int('APP_LATEST_VERSION_BUILD', default=0)
|
||||||
|
APP_DOWNLOAD_URL = env('APP_DOWNLOAD_URL', default='')
|
||||||
|
|
||||||
# 定时财务同步客户列表(临时需求,直接写死不走 env)
|
# 定时财务同步客户列表(临时需求,直接写死不走 env)
|
||||||
FINANCE_SYNC_CUSTOMER_NAMES: list[str] = [
|
FINANCE_SYNC_CUSTOMER_NAMES: list[str] = [
|
||||||
'曾念', '紫琪', '胡肖宇', '歌斯拉-胜利星厂', '胡鼎',
|
'曾念', '紫琪', '胡肖宇', '歌斯拉-胜利星厂', '胡鼎',
|
||||||
@@ -204,6 +211,7 @@ REST_FRAMEWORK = {
|
|||||||
'rest_framework.permissions.IsAuthenticated',
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
],
|
],
|
||||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||||
|
'EXCEPTION_HANDLER': 'flower.exception_handler.custom_exception_handler',
|
||||||
}
|
}
|
||||||
|
|
||||||
# JWT 配置
|
# JWT 配置
|
||||||
|
|||||||
61
flower/test_app_version_api.py
Normal file
61
flower/test_app_version_api.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from flower.app_version import APP_VERSION_CACHE_KEY
|
||||||
|
|
||||||
|
|
||||||
|
class AppVersionAPITest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.delete(APP_VERSION_CACHE_KEY)
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
cache.delete(APP_VERSION_CACHE_KEY)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
APP_LATEST_VERSION_MAJOR=1,
|
||||||
|
APP_LATEST_VERSION_MINOR=2,
|
||||||
|
APP_LATEST_VERSION_BUILD=345,
|
||||||
|
APP_DOWNLOAD_URL="https://example.com/app.apk",
|
||||||
|
)
|
||||||
|
def test_app_version_api_returns_settings_payload_without_auth(self):
|
||||||
|
response = self.client.get("/api/app-version/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data,
|
||||||
|
{
|
||||||
|
"latest_version": {
|
||||||
|
"major": 1,
|
||||||
|
"minor": 2,
|
||||||
|
"build": 345,
|
||||||
|
},
|
||||||
|
"download_url": "https://example.com/app.apk",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_set_app_version_command_updates_cached_api_payload(self):
|
||||||
|
output = StringIO()
|
||||||
|
call_command(
|
||||||
|
"set_app_version",
|
||||||
|
"--major",
|
||||||
|
"2",
|
||||||
|
"--minor",
|
||||||
|
"5",
|
||||||
|
"--build",
|
||||||
|
"1001",
|
||||||
|
"--download-url",
|
||||||
|
"https://example.com/latest.apk",
|
||||||
|
stdout=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get("/api/app-version/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["latest_version"], {"major": 2, "minor": 5, "build": 1001})
|
||||||
|
self.assertEqual(response.data["download_url"], "https://example.com/latest.apk")
|
||||||
|
self.assertIn("'build': 1001", output.getvalue())
|
||||||
115
flower/urls.py
115
flower/urls.py
@@ -15,44 +15,137 @@ Including another URLconf
|
|||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
"""
|
"""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import update_last_login
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||||
from sse.views import create_sse_event, push_test_event, get_sse_status, shutdown_sse
|
from sse.views import create_sse_event, push_test_event, get_sse_status, shutdown_sse
|
||||||
|
from rest_framework_simplejwt.settings import api_settings
|
||||||
|
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||||
from rest_framework_simplejwt.views import (
|
from rest_framework_simplejwt.views import (
|
||||||
TokenObtainPairView,
|
TokenObtainPairView,
|
||||||
# TokenRefreshView,
|
# TokenRefreshView,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from basic_info.models import EmployeeStatusEnum
|
||||||
|
from flower.app_version import AppVersionView
|
||||||
|
from flower.error_code import AuthErrorCode
|
||||||
|
from flower.error_code_views import ErrorCodeListView
|
||||||
|
|
||||||
# 自定义后台站点标题(simpleui 也会读取)
|
# 自定义后台站点标题(simpleui 也会读取)
|
||||||
admin.site.site_header = "宇问科技"
|
admin.site.site_header = "宇问科技"
|
||||||
admin.site.site_title = "宇问科技"
|
admin.site.site_title = "宇问科技"
|
||||||
admin.site.index_title = "管理后台"
|
admin.site.index_title = "管理后台"
|
||||||
|
|
||||||
|
def login_error_response(error_code: AuthErrorCode, code: str, message: str, http_status: int):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'error_code': int(error_code),
|
||||||
|
'code': code,
|
||||||
|
'message': message,
|
||||||
|
'detail': message,
|
||||||
|
},
|
||||||
|
status=http_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CustomTokenObtainPairView(TokenObtainPairView):
|
class CustomTokenObtainPairView(TokenObtainPairView):
|
||||||
"""自定义登录视图"""
|
"""自定义登录视图"""
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
data = request.data or {}
|
||||||
|
username = (data.get('username') or '').strip()
|
||||||
|
password = data.get('password')
|
||||||
|
|
||||||
|
if not username and not password:
|
||||||
|
return login_error_response(
|
||||||
|
AuthErrorCode.MISSING_CREDENTIALS,
|
||||||
|
'missing_credentials',
|
||||||
|
'请输入用户名和密码',
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
if not username:
|
||||||
|
return login_error_response(
|
||||||
|
AuthErrorCode.MISSING_USERNAME,
|
||||||
|
'missing_username',
|
||||||
|
'请输入用户名',
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
if not password:
|
||||||
|
return login_error_response(
|
||||||
|
AuthErrorCode.MISSING_PASSWORD,
|
||||||
|
'missing_password',
|
||||||
|
'请输入密码',
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
UserModel = get_user_model()
|
||||||
try:
|
try:
|
||||||
resp = super().post(request, *args, **kwargs)
|
user = UserModel._default_manager.get_by_natural_key(username)
|
||||||
if resp.status_code == 200:
|
except UserModel.DoesNotExist:
|
||||||
srz = self.get_serializer(data=request.data)
|
return login_error_response(
|
||||||
srz.is_valid()
|
AuthErrorCode.USER_NOT_FOUND,
|
||||||
user = srz.user
|
'user_not_found',
|
||||||
|
'用户不存在',
|
||||||
|
status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
if not hasattr(user, 'employee'):
|
if not user.check_password(password):
|
||||||
# 非员工用户,直接返回登录失败
|
return login_error_response(
|
||||||
return Response({'detail': '无绑定的员工身份'}, status=401)
|
AuthErrorCode.INVALID_PASSWORD,
|
||||||
|
'invalid_password',
|
||||||
|
'密码错误',
|
||||||
|
status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
return resp
|
if not user.is_active:
|
||||||
except Exception as e:
|
return login_error_response(
|
||||||
return Response({'detail': '无法登录'}, status=400)
|
AuthErrorCode.USER_INACTIVE,
|
||||||
|
'user_inactive',
|
||||||
|
'该用户已被禁用',
|
||||||
|
status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
employee = user.employee
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
return login_error_response(
|
||||||
|
AuthErrorCode.EMPLOYEE_NOT_BOUND,
|
||||||
|
'employee_not_bound',
|
||||||
|
'该用户未绑定员工身份',
|
||||||
|
status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
if employee.status != EmployeeStatusEnum.ACTIVE:
|
||||||
|
return login_error_response(
|
||||||
|
AuthErrorCode.EMPLOYEE_INACTIVE,
|
||||||
|
'employee_inactive',
|
||||||
|
'该员工已离职或停用',
|
||||||
|
status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh = TokenObtainPairSerializer.get_token(user)
|
||||||
|
if api_settings.UPDATE_LAST_LOGIN:
|
||||||
|
update_last_login(None, user)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'refresh': str(refresh),
|
||||||
|
'access': str(refresh.access_token),
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# JWT 登录
|
# JWT 登录
|
||||||
path('api/auth/login/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'),
|
path('api/auth/login/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||||
# path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
# path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
|
path('api/error-codes/', ErrorCodeListView.as_view(), name='error_code_list'),
|
||||||
|
path('api/app-version/', AppVersionView.as_view(), name='app_version'),
|
||||||
|
|
||||||
# API 文档
|
# API 文档
|
||||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||||
|
|||||||
Reference in New Issue
Block a user