From 545b235379b8991f7eb50e6d257df2c87e37ab30 Mon Sep 17 00:00:00 2001 From: XingQue Date: Sun, 9 Aug 2026 16:37:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=202H=20=E8=BF=90?= =?UTF-8?q?=E8=90=A5=E5=8F=B0=E7=8B=AC=E7=AB=8B=E7=99=BB=E5=BD=95=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E4=B8=8D=E6=94=B9=E6=97=A7=20kefu-login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jituan/services/eh_console_login.py | 186 ++++++++++++++++++++++++++++ jituan/urls.py | 4 + jituan/views.py | 67 +++++++++- 3 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 jituan/services/eh_console_login.py diff --git a/jituan/services/eh_console_login.py b/jituan/services/eh_console_login.py new file mode 100644 index 0000000..3d9e2e6 --- /dev/null +++ b/jituan/services/eh_console_login.py @@ -0,0 +1,186 @@ +""" +2H 运营台专用登录(与旧 /jituan/auth/kefu-login 完全独立)。 + +- 不修改、不调用旧登录 View +- 校验通过后强制要求可访问目标 club_id(默认 eh) +- 旧 kefu 后台继续只用 kefu-login,互不影响 +""" +from __future__ import annotations + +import logging + +from django.utils import timezone +from rest_framework_simplejwt.tokens import RefreshToken + +from jituan.constants import DATA_SCOPE_SINGLE, SUPER_ADMIN_PHONES +from jituan.services.admin_context import ( + build_admin_club_context, + get_allowed_club_ids, + is_hardcoded_super_phone, + is_system_super_admin, +) +from jituan.services.kefu_menu import build_menu_access_payload +from users.business_models import User + +logger = logging.getLogger(__name__) + +EH_CONSOLE_CLUB_ID = 'eh' + + +def _authenticate_kefu_user(phone: str, password: str, erjimima: str): + """ + 返回 (user, kefu, error_code, error_msg) + error_code: None 表示成功;否则为业务码。 + """ + phone = (phone or '').strip() + password = password or '' + erjimima = erjimima or '' + + if not phone or not password: + return None, None, 1, '请填写手机号和密码' + + user_mains = list( + User.query.filter(Phone=phone, KefuProfile__isnull=False).select_related('KefuProfile') + ) + if not user_mains: + for admin_user in User.query.filter(Phone=phone): + if ( + admin_user.IsSuperuser + or admin_user.UserType == 'admin' + or admin_user.Phone in SUPER_ADMIN_PHONES + ): + user_mains = [admin_user] + break + + if not user_mains: + return None, None, 1, '用户不存在' + + target_user = None + target_kefu = None + password_matched = False + + for user in user_mains: + if not user.CheckPassword(password): + continue + password_matched = True + + try: + kefu_probe = user.KefuProfile + except Exception: + kefu_probe = None + + if kefu_probe is not None and int(getattr(kefu_probe, 'zhuangtai', 1) or 0) != 1: + if not is_hardcoded_super_phone(user): + if target_user is None: + target_user = user + target_kefu = kefu_probe + continue + + is_admin = ( + user.IsSuperuser + or user.UserType == 'admin' + or user.Phone in SUPER_ADMIN_PHONES + ) + if is_admin: + return user, kefu_probe, None, None + + kefu = kefu_probe + if kefu is None: + continue + stored_erji = kefu.erjimima or '' + if stored_erji.startswith(('$2b$', '$2a$')) and len(stored_erji) == 60: + import bcrypt as _bcrypt + if not _bcrypt.checkpw(erjimima.encode('utf-8'), stored_erji.encode('utf-8')): + continue + elif stored_erji != erjimima: + continue + if kefu.zhuangtai != 1: + if target_user is None: + target_user = user + target_kefu = kefu + continue + return user, kefu, None, None + + if target_user and target_kefu and int(getattr(target_kefu, 'zhuangtai', 1) or 0) != 1: + return None, None, 4, '账号已被封禁' + if password_matched: + return None, None, 1, '二级密码错误' + return None, None, 1, '密码错误' + + +def user_can_access_console_club(user, club_id: str) -> bool: + if is_system_super_admin(user): + return True + return club_id in get_allowed_club_ids(user) + + +def login_eh_console(request, phone: str, password: str, erjimima: str, club_id: str = EH_CONSOLE_CLUB_ID): + """ + 2H 运营台登录。 + 返回 (http_status, body_dict) + """ + target_club = (club_id or EH_CONSOLE_CLUB_ID).strip() or EH_CONSOLE_CLUB_ID + user, kefu, err_code, err_msg = _authenticate_kefu_user(phone, password, erjimima) + if err_code is not None: + http = 403 if err_code == 4 else 400 + return http, {'code': err_code, 'msg': err_msg, 'data': None} + + if not user_can_access_console_club(user, target_club): + allowed = sorted(get_allowed_club_ids(user)) + logger.warning( + 'eh-console 拒登 phone=%s want=%s allowed=%s', + phone, target_club, allowed, + ) + return 403, { + 'code': 403, + 'msg': f'无权进入俱乐部「{target_club}」运营台,请使用已在该俱乐部配置的账号', + 'data': { + 'allowed': False, + 'club_id': target_club, + 'allowed_club_ids': allowed, + }, + } + + user.IP = _request_ip(request) + user.UserLastLoginDate = timezone.now() + user.save(update_fields=['IP', 'UserLastLoginDate']) + + token = str(RefreshToken.for_user(user).access_token) + nicheng = kefu.nicheng if kefu else (user.UserName or user.Phone) + club_context = build_admin_club_context(user) + + # 菜单按目标单店 eh 计算(不按集团 all) + request.user = user + request.club_id = target_club + request.club_scope = DATA_SCOPE_SINGLE + menu_access, _ = build_menu_access_payload(request, phone) + + return 200, { + 'code': 0, + 'msg': '登录成功', + 'data': { + 'token': token, + 'nicheng': nicheng, + 'yonghuid': user.UserUID, + 'console': 'eh', + 'fixed_club_id': target_club, + 'club_context': club_context, + 'menu_access': menu_access if menu_access else { + 'visible_page_ids': [], + 'visible_paths': [], + 'permission_codes': [], + 'role_names': [], + 'yonghuid': user.UserUID or '', + 'can_switch_club': False, + 'is_group_admin': False, + 'menu_ready': False, + }, + }, + } + + +def _request_ip(request): + xff = request.META.get('HTTP_X_FORWARDED_FOR') + if xff: + return xff.split(',')[0].strip() + return request.META.get('REMOTE_ADDR') or '' diff --git a/jituan/urls.py b/jituan/urls.py index 85ee8bf..3a94736 100644 --- a/jituan/urls.py +++ b/jituan/urls.py @@ -50,6 +50,8 @@ from jituan.views import ( ClubHuiyuanStatsView, ClubKefuLoginView, ClubKefuMenuAccessView, + EhConsoleLoginView, + ClubConsoleGateView, ClubPermDebugView, ClubListView, ClubDefaultInviteView, @@ -131,8 +133,10 @@ urlpatterns = [ path('auth/wechat-oauth-login', ClubWechatOAuthLoginView.as_view(), name='jituan_wechat_oauth_login'), path('auth/refresh-wx-bind', ClubWxBindRefreshView.as_view(), name='jituan_refresh_wx_bind'), path('auth/kefu-login', ClubKefuLoginView.as_view(), name='jituan_kefu_login'), + path('auth/eh-console-login', EhConsoleLoginView.as_view(), name='jituan_eh_console_login'), path('auth/me-context', ClubMeContextView.as_view(), name='jituan_me_context'), path('auth/menu-access', ClubKefuMenuAccessView.as_view(), name='jituan_menu_access'), + path('auth/console-gate', ClubConsoleGateView.as_view(), name='jituan_console_gate'), path('auth/perm-debug', ClubPermDebugView.as_view(), name='jituan_perm_debug'), path('auth/dashou-register', ClubDashouRegisterView.as_view(), name='jituan_dashou_register'), path('houtai/club-manage', ClubManageView.as_view(), name='jituan_club_manage'), diff --git a/jituan/views.py b/jituan/views.py index 929a1d0..55ffb9f 100644 --- a/jituan/views.py +++ b/jituan/views.py @@ -12,7 +12,13 @@ from rest_framework_simplejwt.tokens import RefreshToken from jituan.constants import SUPER_ADMIN_PHONES, ADMIN_ROLE_LABELS, CLUB_ID_DEFAULT, DATA_SCOPE_ALL, DATA_SCOPE_SINGLE from jituan.models import Club, AdminAssignment -from jituan.services.admin_context import build_admin_club_context, is_kefu_backend_account, can_manage_admin_assignments +from jituan.services.admin_context import ( + build_admin_club_context, + is_kefu_backend_account, + can_manage_admin_assignments, + get_allowed_club_ids, + is_system_super_admin, +) from jituan.services.kefu_menu import build_menu_access_payload from jituan.services.wechat_login import ( login_or_register_by_club, @@ -345,6 +351,65 @@ class ClubKefuMenuAccessView(APIView): return Response({'code': 0, 'msg': 'ok', 'data': payload}) +class EhConsoleLoginView(APIView): + """ + 2H 运营台专用登录(全新接口,与旧后台无关)。 + POST /jituan/auth/eh-console-login + body: { phone, password, erjimima } + + 注意:绝不改动 /jituan/auth/kefu-login;旧 kefu 后台继续用旧登录。 + """ + throttle_classes = [AnonRateThrottle] + permission_classes = [AllowAny] + + def post(self, request): + from jituan.services.eh_console_login import EH_CONSOLE_CLUB_ID, login_eh_console + + phone = (request.data.get('phone') or '').strip() + password = request.data.get('password') or '' + erjimima = request.data.get('erjimima') or '' + http_status, body = login_eh_console( + request, phone, password, erjimima, club_id=EH_CONSOLE_CLUB_ID, + ) + return Response(body, status=http_status) + + +class ClubConsoleGateView(APIView): + """ + GET /jituan/auth/console-gate?club_id=eh + 2H 运营台会话复查门禁(新接口)。不参与旧后台登录。 + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + from jituan.services.eh_console_login import user_can_access_console_club + + user = request.user + if not is_kefu_backend_account(user): + return Response({'code': 403, 'msg': '非后台账号', 'data': None}, status=403) + + target = (request.query_params.get('club_id') or '').strip() or 'eh' + allowed = sorted(get_allowed_club_ids(user)) + ok = bool(user_can_access_console_club(user, target)) + payload = { + 'allowed': ok, + 'club_id': target, + 'allowed_club_ids': allowed, + 'phone': getattr(user, 'Phone', '') or '', + 'is_super': bool(is_system_super_admin(user)), + } + if not ok: + return Response( + { + 'code': 403, + 'msg': f'无权进入俱乐部「{target}」运营台,请使用已在该俱乐部配置的账号', + 'data': payload, + }, + status=status.HTTP_403_FORBIDDEN, + ) + return Response({'code': 0, 'msg': 'ok', 'data': payload}) + + class ClubPermDebugView(APIView): """GET /jituan/auth/perm-debug — 当前俱乐部权限解析明细。"""