diff --git a/api_v1/management/commands/set_app_version.py b/api_v1/management/commands/set_app_version.py new file mode 100644 index 0000000..94c837f --- /dev/null +++ b/api_v1/management/commands/set_app_version.py @@ -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))) diff --git a/api_v1/management/commands/sync_external_printing_records.py b/api_v1/management/commands/sync_external_printing_records.py index ea4ae1d..9bdb648 100644 --- a/api_v1/management/commands/sync_external_printing_records.py +++ b/api_v1/management/commands/sync_external_printing_records.py @@ -8,7 +8,15 @@ class Command(BaseCommand): def add_arguments(self, parser): 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): - 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))) diff --git a/api_v1/tasks.py b/api_v1/tasks.py index ff38634..630eee3 100644 --- a/api_v1/tasks.py +++ b/api_v1/tasks.py @@ -743,6 +743,10 @@ def _upload_product_image(product, image_payload: dict): 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) def backfill_external_product_images( self, @@ -811,23 +815,38 @@ def _ensure_external_product(*, merchant, external_product_name: str, category): if not normalized: raise RuntimeError('外部产品名为空,无法创建/绑定产品') + allows_missing_image = _external_product_allows_missing_image(normalized) existing_product = ( basic_models.Product.objects.filter(merchant=merchant, name=normalized) .order_by('id') .first() ) 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 - image_payload = _fetch_external_product_image(normalized) product = basic_models.Product.objects.create( merchant=merchant, category=category, name=normalized, ) try: + image_payload = _fetch_external_product_image(normalized) _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: try: product.image.delete(save=False) @@ -1876,14 +1895,14 @@ def sync_mdy_plate_orders_for_merchant( @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 接口增量同步印染订单/任务。 策略: - 拉取时强制 update_cursor=false - 按外部 record.ID 处理失败并记录 - - 本批处理结束后无论是否有部分失败,都推进远端 cursor + - 默认在本批处理结束后推进远端 cursor;本地检查可传 advance_cursor=False 禁止推进 """ limit = max(1, int(limit or 100)) sync_user = _get_printing_sync_user() @@ -1908,7 +1927,7 @@ def sync_external_printing_records(self, limit: int = 100): ) 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)) else: batch_result = { @@ -1947,6 +1966,7 @@ def sync_external_printing_records(self, limit: int = 100): 'cursor_before': payload.get('cursor_before'), 'cursor_after': payload.get('cursor_after'), 'cursor_updated': bool(cursor_payload), + 'advance_cursor': bool(advance_cursor), 'group_count': batch_result['group_count'], } logger.info('外部印染 records 同步完成: %s', result) diff --git a/api_v1/test_external_printing_records_sync.py b/api_v1/test_external_printing_records_sync.py index a3d9098..fa910b3 100644 --- a/api_v1/test_external_printing_records_sync.py +++ b/api_v1/test_external_printing_records_sync.py @@ -45,6 +45,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase): merchant=self.merchant, category=self.category, name='Tj1712#12号色-24码', + image='product_images/existing.jpg', ) state = State.objects.create(name='待生产') @@ -232,6 +233,85 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase): self.assertEqual(failure.external_order_id, 'KD20432358') 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._fetch_external_product_image') @patch('api_v1.tasks._fetch_external_printing_records') @@ -324,6 +404,16 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase): self.assertIn("'remaining_failures': 0", output.getvalue()) 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._fetch_external_product_image') @patch('api_v1.tasks._fetch_external_printing_records') diff --git a/api_v1/views/printing/serializers.py b/api_v1/views/printing/serializers.py index c715642..6ab3ce7 100644 --- a/api_v1/views/printing/serializers.py +++ b/api_v1/views/printing/serializers.py @@ -385,6 +385,8 @@ class PrintingJobListSerializer(serializers.ModelSerializer): 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) 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: model = models.PrintingJob @@ -400,6 +402,8 @@ class PrintingJobListSerializer(serializers.ModelSerializer): 'batch_advance_records', 'saleitems', 'fabric', + 'curve', + 'new_curve', 'created_at', 'updated_at' ] read_only_fields = [ @@ -497,6 +501,8 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer): 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) 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: model = models.PrintingJob @@ -511,6 +517,8 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer): 'batch_advance_records', 'saleitems', 'fabric', + 'curve', + 'new_curve', 'created_at', 'updated_at' ] read_only_fields = [ diff --git a/api_v1/views/printing/test_printing_job_api.py b/api_v1/views/printing/test_printing_job_api.py index fe4da3b..d3f4e7e 100644 --- a/api_v1/views/printing/test_printing_job_api.py +++ b/api_v1/views/printing/test_printing_job_api.py @@ -77,6 +77,8 @@ class PrintingJobAPITestCase(TestCase): customer=self.customer, fabric='测试布料', width='150cm', + curve='测试曲线', + new_curve='测试新曲线', process=self.process, ) @@ -191,6 +193,8 @@ class PrintingJobAPITestCase(TestCase): for item in response.data['results']: self.assertIn('customer_name', item) self.assertEqual(item['customer_name'], self.customer.name) + self.assertEqual(item['curve'], '测试曲线') + self.assertEqual(item['new_curve'], '测试新曲线') self.assertIn('batch_advance_records', item) self.assertIsInstance(item['batch_advance_records'], list) 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}/') self.assertEqual(detail.status_code, status.HTTP_200_OK) 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): """测试按数量范围过滤""" diff --git a/api_v2/test_auth_login_api.py b/api_v2/test_auth_login_api.py new file mode 100644 index 0000000..29f902c --- /dev/null +++ b/api_v2/test_auth_login_api.py @@ -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}) diff --git a/api_v2/test_external_printing_order_snapshot_sync_api.py b/api_v2/test_external_printing_order_snapshot_sync_api.py index 0f4b923..fd22531 100644 --- a/api_v2/test_external_printing_order_snapshot_sync_api.py +++ b/api_v2/test_external_printing_order_snapshot_sync_api.py @@ -46,11 +46,13 @@ class PrintingOrderExternalSnapshotSyncAPITest(TestCase): merchant=self.merchant, category=self.category, name='Tj1712#12号色-24码', + image='product_images/keep.jpg', ) self.product_stale = basic_models.Product.objects.create( merchant=self.merchant, category=self.category, name='Tj1712#12号色-25码', + image='product_images/stale.jpg', ) state = stateflow_models.State.objects.create(name='待生产') @@ -233,6 +235,39 @@ class PrintingOrderExternalSnapshotSyncAPITest(TestCase): self.assertEqual(len(audit.before_snapshot['printing_jobs']), 2) 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') def test_sync_records_audit_when_external_snapshot_not_found(self, mock_fetch_snapshot): mock_fetch_snapshot.side_effect = ExternalPrintingOrderSnapshotNotFoundError('未找到该 external_order_id 对应的订单') diff --git a/api_v2/tests.py b/api_v2/tests.py index 4dcdd7f..d0b6ffe 100644 --- a/api_v2/tests.py +++ b/api_v2/tests.py @@ -171,6 +171,8 @@ class PrintingJobByCustomerAPITest(TestCase): customer=self.customer, fabric='棉', width='150cm', + curve='曲线A', + new_curve='新曲线A', external_order_id='KD20410611', ) 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]['sub_id'], 11) 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): resp = self._get({ diff --git a/api_v2/views/printing.py b/api_v2/views/printing.py index 8236834..4c6abe1 100644 --- a/api_v2/views/printing.py +++ b/api_v2/views/printing.py @@ -55,6 +55,8 @@ class PrintingJobV2Serializer(serializers.ModelSerializer): business_object_id = serializers.SerializerMethodField() width = 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) class Meta: @@ -70,6 +72,8 @@ class PrintingJobV2Serializer(serializers.ModelSerializer): 'quantity', 'width', 'fabric', + 'curve', + 'new_curve', 'unit', 'size', 'pieces', diff --git a/celerybeat-schedule b/celerybeat-schedule index 04f010c..44706ac 100644 Binary files a/celerybeat-schedule and b/celerybeat-schedule differ diff --git a/celerybeat-schedule-shm b/celerybeat-schedule-shm deleted file mode 100644 index 96bfba1..0000000 Binary files a/celerybeat-schedule-shm and /dev/null differ diff --git a/celerybeat-schedule-wal b/celerybeat-schedule-wal deleted file mode 100644 index c562b6f..0000000 Binary files a/celerybeat-schedule-wal and /dev/null differ diff --git a/flower/app_version.py b/flower/app_version.py new file mode 100644 index 0000000..49e59ce --- /dev/null +++ b/flower/app_version.py @@ -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()) diff --git a/flower/error_code.py b/flower/error_code.py new file mode 100644 index 0000000..9f33fcd --- /dev/null +++ b/flower/error_code.py @@ -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, + }, +] diff --git a/flower/error_code_registry.py b/flower/error_code_registry.py new file mode 100644 index 0000000..8000216 --- /dev/null +++ b/flower/error_code_registry.py @@ -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', ''), + } diff --git a/flower/error_code_views.py b/flower/error_code_views.py new file mode 100644 index 0000000..a127494 --- /dev/null +++ b/flower/error_code_views.py @@ -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()}) diff --git a/flower/exception_handler.py b/flower/exception_handler.py new file mode 100644 index 0000000..fdaec1f --- /dev/null +++ b/flower/exception_handler.py @@ -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) diff --git a/flower/settings.py b/flower/settings.py index e695d7e..cf2333c 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -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),收/付款金额保留两位 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) FINANCE_SYNC_CUSTOMER_NAMES: list[str] = [ '曾念', '紫琪', '胡肖宇', '歌斯拉-胜利星厂', '胡鼎', @@ -204,6 +211,7 @@ REST_FRAMEWORK = { 'rest_framework.permissions.IsAuthenticated', ], 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'EXCEPTION_HANDLER': 'flower.exception_handler.custom_exception_handler', } # JWT 配置 diff --git a/flower/test_app_version_api.py b/flower/test_app_version_api.py new file mode 100644 index 0000000..74bc235 --- /dev/null +++ b/flower/test_app_version_api.py @@ -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()) diff --git a/flower/urls.py b/flower/urls.py index 530ef30..d00fda5 100644 --- a/flower/urls.py +++ b/flower/urls.py @@ -15,44 +15,137 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ 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 rest_framework import status from rest_framework.response import Response from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView 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 ( TokenObtainPairView, # 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 也会读取) admin.site.site_header = "宇问科技" admin.site.site_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): """自定义登录视图""" 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: - resp = super().post(request, *args, **kwargs) - if resp.status_code == 200: - srz = self.get_serializer(data=request.data) - srz.is_valid() - user = srz.user + user = UserModel._default_manager.get_by_natural_key(username) + except UserModel.DoesNotExist: + return login_error_response( + AuthErrorCode.USER_NOT_FOUND, + 'user_not_found', + '用户不存在', + status.HTTP_401_UNAUTHORIZED, + ) - if not hasattr(user, 'employee'): - # 非员工用户,直接返回登录失败 - return Response({'detail': '无绑定的员工身份'}, status=401) + if not user.check_password(password): + return login_error_response( + AuthErrorCode.INVALID_PASSWORD, + 'invalid_password', + '密码错误', + status.HTTP_401_UNAUTHORIZED, + ) - return resp - except Exception as e: - return Response({'detail': '无法登录'}, status=400) + if not user.is_active: + return login_error_response( + 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 = [ # JWT 登录 path('api/auth/login/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), # 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 文档 path('api/schema/', SpectacularAPIView.as_view(), name='schema'),