"""集团平台 API 视图。""" import logging from django.utils import timezone from rest_framework import status from rest_framework.parsers import JSONParser from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.throttling import AnonRateThrottle 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, can_manage_admin_assignments from jituan.services.kefu_menu import build_menu_access_payload from jituan.services.wechat_login import login_or_register_by_club from jituan.services.caiwu_stats import build_caiwu_payload from jituan.services.szxx_stats import build_szxx_payload from jituan.services.admin_assignments import ( create_or_reactivate_assignment, deactivate_assignment, dedupe_active_assignments, ) from jituan.services.user_stats import build_user_summary from jituan.services.finance_detail_stats import ( build_chongzhi_finance_payload, build_huiyuan_bankuai_payload, build_huiyuan_stats_payload, build_order_finance_payload, build_order_types_payload, ) from backend.utils import verify_kefu_permission from users.business_models import User logger = logging.getLogger(__name__) def _client_ip(request): xff = request.META.get('HTTP_X_FORWARDED_FOR') if xff: return xff.split(',')[0].strip() return request.META.get('REMOTE_ADDR', '') class ClubWechatLoginView(APIView): """ 多俱乐部微信小程序登录(新接口,旧 /yonghu/wechatlogin 不变)。 POST /jituan/auth/wechat-login body: { code, club_id?, app_id? } """ throttle_classes = [AnonRateThrottle] permission_classes = [AllowAny] def post(self, request): code = (request.data.get('code') or '').strip() club_id = (request.data.get('club_id') or '').strip() or None app_id = (request.data.get('app_id') or '').strip() or None if not code: return Response({'code': 1, 'msg': '微信授权码不能为空', 'data': None}, status=status.HTTP_400_BAD_REQUEST) try: data, err = login_or_register_by_club( code, club_id=club_id, app_id=app_id, client_ip=_client_ip(request), ) if err: return Response({'code': 2, 'msg': f'微信登录失败: {err}', 'data': None}, status=status.HTTP_400_BAD_REQUEST) return Response({'code': 0, 'msg': '登录成功', 'data': data}) except Exception as e: logger.exception('ClubWechatLoginView 异常') return Response({'code': 99, 'msg': '系统繁忙,请稍后重试', 'data': None}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) class ClubKefuLoginView(APIView): """ 多俱乐部客服/集团后台登录(新接口,旧 /yonghu/kefujinru 不变)。 POST /jituan/auth/kefu-login body: { phone, password, erjimima } """ throttle_classes = [AnonRateThrottle] permission_classes = [AllowAny] def post(self, request): phone = (request.data.get('phone') or '').strip() password = request.data.get('password') or '' erjimima = request.data.get('erjimima') or '' if not phone or not password: return Response({'code': 1, 'msg': '请填写手机号和密码', 'data': None}, status=status.HTTP_400_BAD_REQUEST) user_mains = list( User.query.filter(Phone=phone, KefuProfile__isnull=False).select_related('KefuProfile') ) if not user_mains: admin_candidates = User.query.filter(Phone=phone) for admin_user in admin_candidates: 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 Response({'code': 1, 'msg': '用户不存在', 'data': None}, status=status.HTTP_400_BAD_REQUEST) target_user = None target_kefu = None password_matched = False for user in user_mains: if not user.CheckPassword(password): continue password_matched = True is_admin = user.IsSuperuser or user.UserType == 'admin' or user.Phone in SUPER_ADMIN_PHONES if is_admin: target_user = user target_kefu = None break kefu = user.KefuProfile 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 target_user = user target_kefu = kefu break else: if target_user and target_kefu: return Response({'code': 4, 'msg': '账号已被封禁', 'data': None}, status=status.HTTP_403_FORBIDDEN) if password_matched: return Response({'code': 1, 'msg': '二级密码错误', 'data': None}, status=status.HTTP_400_BAD_REQUEST) return Response({'code': 1, 'msg': '密码错误', 'data': None}, status=status.HTTP_400_BAD_REQUEST) target_user.IP = _client_ip(request) target_user.UserLastLoginDate = timezone.now() target_user.save(update_fields=['IP', 'UserLastLoginDate']) refresh = RefreshToken.for_user(target_user) token = str(refresh.access_token) nicheng = target_kefu.nicheng if target_kefu else (target_user.UserName or target_user.Phone) club_context = build_admin_club_context(target_user) # 登录请求尚未带 JWT,需用本次登录用户计算菜单 request.user = target_user menu_access, _menu_err = build_menu_access_payload(request, phone) return Response({ 'code': 0, 'msg': '登录成功', 'data': { 'token': token, 'nicheng': nicheng, 'yonghuid': target_user.UserUID, 'club_context': club_context, 'menu_access': menu_access if menu_access else { 'visible_page_ids': [], 'visible_paths': [], 'can_switch_club': False, 'is_group_admin': False, }, }, }) class ClubListView(APIView): """GET /jituan/club/list — 启用中的俱乐部列表(登录前后均可读)。""" permission_classes = [AllowAny] def get(self, request): clubs = Club.query.filter(status=1).order_by('sort_order', 'club_id') data = [ { 'club_id': c.club_id, 'name': c.name, 'wx_appid': c.wx_appid, 'h5_domain': c.h5_domain, } for c in clubs ] return Response({'code': 0, 'msg': 'ok', 'data': data}) class ClubMeContextView(APIView): """GET /jituan/auth/me-context — 已登录客服刷新俱乐部上下文。""" permission_classes = [IsAuthenticated] def get(self, request): user = request.user if user.UserType not in ('kefu', 'admin') and not user.IsSuperuser: return Response({'code': 403, 'msg': '非后台账号', 'data': None}, status=403) return Response({ 'code': 0, 'msg': 'ok', 'data': build_admin_club_context(user), }) class ClubKefuMenuAccessView(APIView): """GET /jituan/auth/menu-access — 已登录客服可见侧栏菜单。""" permission_classes = [IsAuthenticated] def get(self, request): user = request.user if user.UserType not in ('kefu', 'admin') and not user.IsSuperuser: return Response({'code': 403, 'msg': '非后台账号', 'data': None}, status=403) payload, err = build_menu_access_payload(request) if err is not None: return err return Response({'code': 0, 'msg': 'ok', 'data': payload}) class ClubAdminAssignmentListView(APIView): """POST /jituan/houtai/admin-assignments — 数据范围任职(非 gvsdsdk 功能权限)。""" permission_classes = [IsAuthenticated] parser_classes = [JSONParser] def _is_super(self, user): return ( bool(user.IsSuperuser) or user.UserType == 'admin' or getattr(user, 'Phone', '') in SUPER_ADMIN_PHONES ) def _list_rows(self, assignments): club_name_map = {c.club_id: c.name for c in Club.query.filter(status=1)} uids = list({a.yonghuid for a in assignments}) user_map = {} if uids: for u in User.query.filter(UserUID__in=uids).only('UserUID', 'Phone', 'UserName'): user_map[u.UserUID] = u.Phone or u.UserName or u.UserUID rows = [] for a in assignments: club_label = '集团(全部子公司)' if a.club_id: club_label = club_name_map.get(a.club_id, a.club_id) rows.append({ 'id': a.id, 'yonghuid': a.yonghuid, 'phone': user_map.get(a.yonghuid, ''), 'club_id': a.club_id, 'club_label': club_label, 'role_code': a.role_code, 'role_name': ADMIN_ROLE_LABELS.get(a.role_code, a.role_code), 'data_scope': a.data_scope, 'is_primary': a.is_primary, }) return rows def post(self, request): try: username = (request.data.get('phone') or request.data.get('username') or '').strip() if not username: return Response({'code': 401, 'msg': '缺少账号'}, status=401) kefu_obj, permissions = verify_kefu_permission(request, username) if kefu_obj is None: return permissions user = request.user is_super = self._is_super(user) action = (request.data.get('action') or 'list').strip() if action in ('create', 'update', 'delete'): if not can_manage_admin_assignments(user, permissions): return Response({'code': 403, 'msg': '无权限维护任职数据(需超管或集团超管任职)'}, status=403) if action == 'create': yonghuid = (request.data.get('yonghuid') or '').strip() phone = (request.data.get('target_phone') or '').strip() if not yonghuid and phone: target = User.query.filter(Phone=phone).first() if not target: return Response({'code': 404, 'msg': f'未找到手机号 {phone}'}) yonghuid = target.UserUID if not yonghuid: return Response({'code': 400, 'msg': '需要 yonghuid 或 target_phone'}) club_id = request.data.get('club_id') if club_id is not None and str(club_id).strip() == '': club_id = None elif club_id is not None: club_id = str(club_id).strip() role_code = (request.data.get('role_code') or 'CLUB_ADMIN').strip() data_scope = (request.data.get('data_scope') or DATA_SCOPE_SINGLE).strip() if club_id is None: data_scope = DATA_SCOPE_ALL is_primary = bool(request.data.get('is_primary', False)) _, err = create_or_reactivate_assignment( yonghuid=yonghuid, club_id=club_id, role_code=role_code, data_scope=data_scope, is_primary=is_primary, granted_by=user.UserUID, ) if err: return Response({'code': 400, 'msg': err}) return Response({'code': 0, 'msg': '任职已添加'}) if action == 'delete': assignment_id = request.data.get('id') if not assignment_id: return Response({'code': 400, 'msg': '缺少任职 id'}) ok, err = deactivate_assignment(assignment_id) if not ok: return Response({'code': 404, 'msg': err or '停用失败'}) dedupe_active_assignments() return Response({'code': 0, 'msg': '任职已停用'}) if action == 'update': assignment_id = request.data.get('id') if not assignment_id: return Response({'code': 400, 'msg': '缺少任职 id'}) row = AdminAssignment.query.filter(id=assignment_id, status=1).first() if not row: return Response({'code': 404, 'msg': '任职记录不存在'}) if 'role_code' in request.data: row.role_code = (request.data.get('role_code') or '').strip() or row.role_code if 'data_scope' in request.data: row.data_scope = request.data.get('data_scope') or row.data_scope if 'is_primary' in request.data: row.is_primary = bool(request.data.get('is_primary')) if 'club_id' in request.data: cid = request.data.get('club_id') row.club_id = None if cid is None or str(cid).strip() == '' else str(cid).strip() if row.club_id is None: row.data_scope = DATA_SCOPE_ALL row.granted_by = user.UserUID row.save() return Response({'code': 0, 'msg': '任职已更新'}) if is_super: dedupe_active_assignments() qs = AdminAssignment.query.filter(status=1).order_by('yonghuid', '-is_primary', 'club_id') else: qs = AdminAssignment.query.filter(yonghuid=user.UserUID, status=1).order_by('-is_primary') return Response({ 'code': 0, 'msg': 'ok', 'data': { 'list': self._list_rows(qs.to_list()), 'can_manage': can_manage_admin_assignments(user, permissions), 'perm_note': ( '【功能权限】在「角色管理」里给账号绑 gvsdsdk 角色(perm_code 如 caiwu、002ab、000001);' '【数据范围】在本页维护 admin_assignment(能看哪个俱乐部/集团汇总)。' '两套独立:集团高管需在本页配「集团全部」任职 + 角色管理里勾功能菜单。' ), 'current_context': build_admin_club_context(user), }, }) except Exception: logger.exception('ClubAdminAssignmentListView 异常') return Response({'code': 99, 'msg': '任职数据操作失败,请确认已执行 migrate jituan'}, status=500) class ClubManageView(APIView): """POST /jituan/houtai/club-manage — 俱乐部主数据与密钥配置(超级管理员)。""" permission_classes = [IsAuthenticated] parser_classes = [JSONParser] _EDITABLE_FIELDS = ( 'name', 'status', 'wx_appid', 'wx_secret', 'mch_id', 'pay_app_id', 'api_v3_key', 'cert_serial_no', 'private_key_path', 'platform_cert_dir', 'official_appid', 'official_secret', 'official_token', 'encoding_aes_key', 'template_id', 'template_max_per_minute', 'h5_domain', 'oss_prefix', 'goeasy_appkey', 'goeasy_secret', 'sort_order', ) def _serialize(self, club, full=False): base = { 'club_id': club.club_id, 'name': club.name, 'status': club.status, 'wx_appid': club.wx_appid, 'h5_domain': club.h5_domain, 'oss_prefix': club.oss_prefix, 'mch_id': club.mch_id, 'pay_app_id': club.pay_app_id, 'goeasy_appkey': club.goeasy_appkey, 'template_id': club.template_id, 'sort_order': club.sort_order, } if full: base.update({ 'wx_secret': club.wx_secret, 'api_v3_key': club.api_v3_key, 'cert_serial_no': club.cert_serial_no, 'private_key_path': club.private_key_path, 'platform_cert_dir': club.platform_cert_dir, 'official_appid': club.official_appid, 'official_secret': club.official_secret, 'official_token': club.official_token, 'encoding_aes_key': club.encoding_aes_key, 'goeasy_secret': club.goeasy_secret, 'template_max_per_minute': club.template_max_per_minute, }) return base def post(self, request): from jituan.constants import CLUB_ID_DEFAULT try: username = (request.data.get('phone') or request.data.get('username') or '').strip() if not username: return Response({'code': 401, 'msg': '缺少账号'}, status=401) kefu_obj, permissions = verify_kefu_permission(request, username) if kefu_obj is None: return permissions if '000001' not in permissions: return Response({'code': 403, 'msg': '仅超级管理员可管理俱乐部配置'}, status=403) action = (request.data.get('action') or 'get').strip() club_id = (request.data.get('club_id') or '').strip() or CLUB_ID_DEFAULT if action == 'list': clubs = Club.query.order_by('sort_order', 'club_id') return Response({ 'code': 0, 'data': [self._serialize(c, full=False) for c in clubs], }) club = Club.query.filter(club_id=club_id).first() if not club and action != 'create': return Response({'code': 1, 'msg': f'俱乐部 {club_id} 不存在'}) if action == 'get': return Response({'code': 0, 'data': self._serialize(club, full=True)}) if action == 'update': update_fields = [] for field in self._EDITABLE_FIELDS: if field in request.data: setattr(club, field, request.data[field]) update_fields.append(field) if update_fields: club.save(update_fields=update_fields) return Response({ 'code': 0, 'msg': '保存成功', 'data': self._serialize(club, full=True), }) if action == 'create': from jituan.services.club_bootstrap import bootstrap_club_from_template new_club_id = (request.data.get('new_club_id') or request.data.get('club_id') or '').strip() name = (request.data.get('name') or '').strip() if not new_club_id or not name: return Response({'code': 400, 'msg': '新建俱乐部需要 new_club_id 与 name'}) try: club = bootstrap_club_from_template( new_club_id, name, wx_appid=(request.data.get('wx_appid') or '').strip(), template_club_id=(request.data.get('from_club') or CLUB_ID_DEFAULT).strip(), ) except ValueError as exc: return Response({'code': 400, 'msg': str(exc)}) return Response({ 'code': 0, 'msg': '俱乐部创建成功', 'data': self._serialize(club, full=True), }) return Response({'code': 400, 'msg': '无效 action,支持 list/get/update/create'}) except Exception: logger.exception('ClubManageView 异常') return Response({'code': 99, 'msg': '俱乐部配置操作失败'}, status=500) class ClubCaiwuView(APIView): """ 俱乐部维度财务(新接口)。 旧 POST /houtai/caiwu 完全不变;kefu 切换俱乐部后应调本接口。 Header: X-Club-Id, 集团汇总 X-Club-Scope: all """ permission_classes = [IsAuthenticated] def post(self, request): username = request.data.get('phone', None) kefu_obj, permissions = verify_kefu_permission(request, username) if kefu_obj is None: return permissions if 'caiwu' not in permissions: return Response({'code': 403, 'msg': '无财务查看权限'}, status=403) try: data = build_caiwu_payload(request) return Response({'code': 0, 'msg': 'ok', 'data': data}) except Exception: logger.exception('ClubCaiwuView 异常') return Response({'code': 99, 'msg': '统计失败', 'data': None}, status=500) class ClubSzxxView(APIView): """ 俱乐部维度每日收支详细统计(新接口)。 旧 POST /houtai/szxx 不变;参数与旧接口一致。 """ permission_classes = [IsAuthenticated] def post(self, request): username = request.data.get('phone', None) kefu_obj, permissions = verify_kefu_permission(request, username) if kefu_obj is None: return permissions if 'caiwu' not in permissions: return Response({'code': 403, 'msg': '无财务查看权限'}, status=403) granularity = request.data.get('granularity', 'day') year = request.data.get('year') month = request.data.get('month') summary_date = request.data.get('summary_date') try: if year is not None: year = int(year) if month is not None: month = int(month) except (ValueError, TypeError): return Response({'code': 1, 'msg': '年份或月份格式错误'}, status=400) try: data, err = build_szxx_payload(request, granularity, year, month, summary_date) if err: return Response({'code': 1, 'msg': err}, status=400) return Response({'code': 0, 'msg': 'ok', 'data': data}) except Exception: logger.exception('ClubSzxxView 异常') return Response({'code': 99, 'msg': '统计失败'}, status=500) class ClubAuditLogListView(APIView): """ 后台审计日志(admin_audit_log)。 POST /jituan/houtai/audit-log 权限:000001 或 czrz666(与操作日志一致) """ permission_classes = [IsAuthenticated] def post(self, request): username = (request.data.get('username') or request.data.get('phone') or '').strip() if not username: return Response({'code': 401, 'msg': '缺少username'}) kefu, permissions = verify_kefu_permission(request, username) if kefu is None: return permissions if '000001' not in permissions and 'czrz666' not in permissions: return Response({'code': 403, 'msg': '无权限查看审计日志'}) try: page = int(request.data.get('page', 1)) except (ValueError, TypeError): page = 1 try: page_size = int(request.data.get('page_size', 20)) except (ValueError, TypeError): page_size = 20 data = list_admin_audit_logs( request, page=page, page_size=page_size, operator_yonghuid=request.data.get('operator_yonghuid'), target_yonghuid=request.data.get('target_yonghuid'), action=request.data.get('action'), keyword=request.data.get('keyword'), ) return Response({'code': 0, 'msg': 'ok', 'data': data}) def _parse_year_month(data): year = data.get('year') month = data.get('month') try: if year is not None: year = int(year) if month is not None: month = int(month) except (ValueError, TypeError): return None, None, Response({'code': 1, 'msg': '年份或月份格式错误'}, status=400) return year, month, None class ClubOrderTypesView(APIView): """POST /jituan/houtai/cwddhqlx — 订单财务商品类型筛选。""" permission_classes = [IsAuthenticated] def post(self, request): username = request.data.get('phone', None) kefu_obj, permissions = verify_kefu_permission(request, username) if kefu_obj is None: return permissions if 'dingdancaiwu' not in permissions: return Response({'code': 403, 'msg': '无订单财务查看权限'}, status=403) return Response({'code': 0, 'msg': 'ok', 'data': build_order_types_payload()}) class ClubOrderFinanceView(APIView): """POST /jituan/houtai/cwhqjtddsj — 订单财务详细统计(按俱乐部)。""" permission_classes = [IsAuthenticated] def post(self, request): username = request.data.get('phone', None) kefu_obj, permissions = verify_kefu_permission(request, username) if kefu_obj is None: return permissions if 'dingdancaiwu' not in permissions: return Response({'code': 403, 'msg': '无订单财务查看权限'}, status=403) year, month, err_resp = _parse_year_month(request.data) if err_resp: return err_resp try: data, err = build_order_finance_payload( request, request.data.get('granularity', 'day'), year, month, request.data.get('type_id', 0), request.data.get('order_source', 0), request.data.get('summary_date'), ) if err: return Response({'code': 1, 'msg': err}, status=400) return Response({'code': 0, 'msg': 'ok', 'data': data}) except Exception: logger.exception('ClubOrderFinanceView 异常') return Response({'code': 99, 'msg': '统计失败'}, status=500) class ClubChongzhiFinanceView(APIView): """POST /jituan/houtai/cwqtczhq — 其他充值/罚款财务统计(按俱乐部)。""" permission_classes = [IsAuthenticated] def post(self, request): username = request.data.get('phone', None) kefu_obj, permissions = verify_kefu_permission(request, username) if kefu_obj is None: return permissions if 'qtczcaiwu' not in permissions: return Response({'code': 403, 'msg': '无其他充值财务查看权限'}, status=403) year, month, err_resp = _parse_year_month(request.data) if err_resp: return err_resp try: data, err = build_chongzhi_finance_payload( request, request.data.get('granularity', 'day'), year, month, request.data.get('types', [2, 3, 4, 5, 6]), request.data.get('summary_date'), ) if err: return Response({'code': 1, 'msg': err}, status=400) return Response({'code': 0, 'msg': 'ok', 'data': data}) except Exception: logger.exception('ClubChongzhiFinanceView 异常') return Response({'code': 99, 'msg': '统计失败'}, status=500) class ClubHuiyuanBankuaiView(APIView): """POST /jituan/houtai/cwhybkhq — 板块及会员列表(附带俱乐部售价)。""" permission_classes = [IsAuthenticated] def post(self, request): username = request.data.get('phone', None) kefu_obj, permissions = verify_kefu_permission(request, username) if kefu_obj is None: return permissions if 'huiyuancaiwu' not in permissions: return Response({'code': 403, 'msg': '无会员财务查看权限'}, status=403) payload = build_huiyuan_bankuai_payload(request) return Response({ 'code': 0, 'msg': 'ok', 'data': payload['bankuai_list'], 'club_id': payload['club_id'], 'scope': payload['scope'], }) class ClubHuiyuanStatsView(APIView): """POST /jituan/houtai/hybkjtsj — 会员财务详细统计(按俱乐部)。""" permission_classes = [IsAuthenticated] def post(self, request): username = request.data.get('phone', None) kefu_obj, permissions = verify_kefu_permission(request, username) if kefu_obj is None: return permissions if 'huiyuancaiwu' not in permissions: return Response({'code': 403, 'msg': '无会员财务查看权限'}, status=403) year, month, err_resp = _parse_year_month(request.data) if err_resp: return err_resp try: data, err = build_huiyuan_stats_payload( request, request.data.get('huiyuan_id'), request.data.get('granularity', 'day'), year, month, request.data.get('include_guanshi', False), request.data.get('include_zuzhang', False), request.data.get('summary_date'), ) if err: return Response({'code': 1, 'msg': err}, status=400) return Response({'code': 0, 'msg': 'ok', 'data': data}) except Exception: logger.exception('ClubHuiyuanStatsView 异常') return Response({'code': 99, 'msg': '统计失败'}, status=500) class ClubUserSummaryView(APIView): """ 用户管理汇总(打手/管事/商家/组长数量)。 集团视图 X-Club-Scope: all 时额外返回 by_club 各公司拆分。 POST /jituan/houtai/user-summary """ permission_classes = [IsAuthenticated] def post(self, request): username = (request.data.get('phone') or request.data.get('username') or '').strip() if not username: return Response({'code': 401, 'msg': '缺少账号'}, status=401) kefu_obj, permissions = verify_kefu_permission(request, username) if kefu_obj is None: return permissions try: data = build_user_summary(request) return Response({'code': 0, 'msg': 'ok', 'data': data}) except Exception: logger.exception('ClubUserSummaryView 异常') return Response({'code': 99, 'msg': '统计失败'}, status=500) class ClubDashouRegisterView(APIView): """ 打手邀请码注册(带俱乐部校验)。 POST /jituan/auth/dashou-register body: { inviteCode } 与 /yonghu/dashouzhuce 逻辑一致,推荐新小程序使用。 """ permission_classes = [IsAuthenticated] def post(self, request): from users.views import DashouZhuceView return DashouZhuceView().post(request)