forked from erp-dev/erp
feat: error_code + api doc + printing-job fields
This commit is contained in:
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),收/付款金额保留两位
|
||||
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 配置
|
||||
|
||||
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'))
|
||||
"""
|
||||
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'),
|
||||
|
||||
Reference in New Issue
Block a user