"""跨俱乐部资产迁移 API(用户端 + 客服后台)。全新接口,旧逻辑零侵入。""" from __future__ import annotations import logging from datetime import datetime from django.db.models import Q from django.utils import timezone from django.utils.dateparse import parse_datetime from rest_framework.parsers import JSONParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from backend.utils import verify_kefu_permission from jituan.models import ClubMigrateLedger, ClubMigratePlan, ClubMigrateUserQuota from jituan.services import club_migrate as migrate_svc from jituan.services.admin_context import can_manage_admin_assignments from jituan.services.club_migrate_assets import catalog_for_admin, normalize_whitelist logger = logging.getLogger(__name__) def _auth_group_admin(request): """仅集团超管(系统超管 / GROUP_OWNER / GROUP_SUPER_ADMIN)。俱乐部 000001 不可见不可调。""" username = (request.data.get('username') or request.data.get('phone') or '').strip() if not username: return None, None, Response({'code': 400, 'msg': '缺少username'}) kefu, permissions = verify_kefu_permission(request, username) if kefu is None: return None, None, permissions if not can_manage_admin_assignments(request.user, permissions): return None, None, Response({'code': 403, 'msg': '仅集团超管可管理跨店资产迁移'}) return kefu, permissions, None def _user_club(user) -> str: return str(getattr(user, 'ClubID', '') or '').strip() def _resolve_club_id(request) -> str: """小程序 club 优先:body.club_id → X-Club-Id → 用户 ClubID。""" club_id = str(request.data.get('club_id') or '').strip() if not club_id: club_id = ( request.META.get('HTTP_X_CLUB_ID') or request.headers.get('X-Club-Id') or '' ).strip() if not club_id: club_id = _user_club(request.user) return club_id def _no_source_plan_response(club_id: str): """无源店计划时给可区分文案(避免前端笼统「暂无迁移计划」)。""" cid = (club_id or '').strip() or '(空)' target = migrate_svc.find_active_plan_for_target(club_id) if club_id else None if target: msg = ( f'本店({cid})是迁入目标店,请到源店小程序' f'({target.from_club_id})完成迁出' ) else: msg = f'当前俱乐部({cid})无有效迁出计划,请确认后台源店/启用/时间窗' return Response({'code': 400, 'msg': msg, 'data': {'club_id': cid, 'is_target': bool(target)}}) def _user_roles(user) -> list: roles = [] for role, attr in ( ('dashou', 'DashouProfile'), ('shangjia', 'ShopProfile'), ('guanshi', 'GuanshiProfile'), ('zuzhang', 'ZuzhangProfile'), ('shenheguan', 'ShenheguanProfile'), ): try: getattr(user, attr) roles.append(role) except Exception: pass return roles def _parse_dt(v): if not v: return None if isinstance(v, datetime): return v if timezone.is_aware(v) else timezone.make_aware(v) dt = parse_datetime(str(v).replace('Z', '+00:00')) if dt is None: return None if timezone.is_naive(dt): dt = timezone.make_aware(dt, timezone.get_current_timezone()) return dt # ==================== 小程序用户端 ==================== class ClubMigrateGateView(APIView): """POST /jituan/migrate/gate""" permission_classes = [IsAuthenticated] parser_classes = [JSONParser] def post(self, request): club_id = _resolve_club_id(request) data = migrate_svc.gate_for_user( club_id=club_id, user_uid=str(request.user.UserUID), roles=_user_roles(request.user), ) data['resolved_club_id'] = club_id or '' return Response({'code': 0, 'msg': 'ok', 'data': data}) class ClubMigratePreviewView(APIView): """POST /jituan/migrate/preview""" permission_classes = [IsAuthenticated] parser_classes = [JSONParser] def post(self, request): club_id = _resolve_club_id(request) plan = migrate_svc.find_active_plan_for_source(club_id) if not plan: return _no_source_plan_response(club_id) uid = str(request.user.UserUID) try: ok, msg = migrate_svc.check_conditions(plan, uid) assets = migrate_svc.preview_assets(plan, uid) except Exception as e: logger.exception('migrate preview failed club=%s uid=%s: %s', club_id, uid, e) return Response({'code': 500, 'msg': f'预览加载失败:{e}'}) return Response({ 'code': 0, 'msg': 'ok', 'data': { 'plan': migrate_svc.serialize_plan(plan), 'assets': assets, 'condition_ok': ok, 'condition_msg': msg, 'can_migrate': migrate_svc.can_migrate_again(plan, uid), 'success_count': migrate_svc.success_count(plan.plan_id, uid), 'max_allowed': migrate_svc.max_allowed_success(plan, uid), 'resolved_club_id': club_id or '', }, }) class ClubMigrateSetPasswordView(APIView): """POST /jituan/migrate/set_password — 设密并发令牌+目标店码""" permission_classes = [IsAuthenticated] parser_classes = [JSONParser] def post(self, request): club_id = _resolve_club_id(request) password = str(request.data.get('password') or '').strip() password2 = str(request.data.get('password2') or '').strip() if password != password2: return Response({'code': 400, 'msg': '两次密码不一致'}) plan = migrate_svc.find_active_plan_for_source(club_id) if not plan: return _no_source_plan_response(club_id) token_row, err = migrate_svc.set_password_and_issue( plan=plan, from_uid=str(request.user.UserUID), password=password, ) if err: return Response({'code': 400, 'msg': err}) from jituan.services.club_migrate_qr import generate_migrate_qrcode qr_url, qr_err = generate_migrate_qrcode(to_club_id=plan.to_club_id, token=token_row.token) if qr_err: logger.warning('migrate qr gen fail: %s', qr_err) # 令牌已签发,前端仍可用 scene;码失败单独提示 else: token_row.qrcode_url = qr_url token_row.save(update_fields=['qrcode_url', 'UpdateTime']) data = migrate_svc.serialize_token(token_row) if qr_err: data['qr_error'] = qr_err return Response({'code': 0, 'msg': '已设密并发码', 'data': data}) class ClubMigrateIssueQrView(APIView): """POST /jituan/migrate/issue_qr — 对已签发令牌重出码""" permission_classes = [IsAuthenticated] parser_classes = [JSONParser] def post(self, request): from jituan.models import ClubMigrateToken from jituan.services.club_migrate_qr import generate_migrate_qrcode club_id = _resolve_club_id(request) uid = str(request.user.UserUID) plan = migrate_svc.find_active_plan_for_source(club_id) if not plan: return _no_source_plan_response(club_id) token_row = ( ClubMigrateToken.query.filter( plan_id=plan.plan_id, from_uid=uid, status=ClubMigrateToken.STATUS_ISSUED, ) .order_by('-id') .first() ) if not token_row: return Response({'code': 400, 'msg': '请先设置转移密码'}) qr_url, qr_err = generate_migrate_qrcode(to_club_id=plan.to_club_id, token=token_row.token) if qr_err: return Response({'code': 400, 'msg': qr_err}) token_row.qrcode_url = qr_url token_row.save(update_fields=['qrcode_url', 'UpdateTime']) return Response({'code': 0, 'msg': 'ok', 'data': migrate_svc.serialize_token(token_row)}) class ClubMigrateConfirmView(APIView): """POST /jituan/migrate/confirm — 目标店确认过户""" permission_classes = [IsAuthenticated] parser_classes = [JSONParser] def post(self, request): token = str(request.data.get('token') or '').strip() scene = str(request.data.get('scene') or '').strip() if not token and scene: token = migrate_svc.parse_migrate_scene(scene) password = str(request.data.get('password') or '').strip() if not token: return Response({'code': 400, 'msg': '缺少令牌'}) # 必须在目标店小程序确认(防止源店误点确认) club_id = _resolve_club_id(request) from jituan.models import ClubMigrateToken row = ClubMigrateToken.query.filter(token=token).first() if row and club_id and str(row.to_club_id) != club_id: return Response({ 'code': 400, 'msg': f'请用目标店小程序({row.to_club_id})扫码确认,当前店={club_id}', }) data, err = migrate_svc.confirm_migrate( token=token, password=password, to_uid=str(request.user.UserUID), client_meta={ 'club_id': club_id or _user_club(request.user), 'ip': request.META.get('REMOTE_ADDR', ''), }, ) if err: return Response({'code': 400, 'msg': err}) return Response({'code': 0, 'msg': '迁移成功', 'data': data}) class ClubMigrateMyRecordsView(APIView): """POST /jituan/migrate/my_records""" permission_classes = [IsAuthenticated] parser_classes = [JSONParser] def post(self, request): limit = request.data.get('limit') or 50 try: limit = int(limit) except (TypeError, ValueError): limit = 50 rows = migrate_svc.list_user_records(str(request.user.UserUID), limit=limit) return Response({'code': 0, 'msg': 'ok', 'data': {'list': rows}}) class ClubMigrateTokenInfoView(APIView): """POST /jituan/migrate/token_info — 目标店扫码后预览(不验密)""" permission_classes = [IsAuthenticated] parser_classes = [JSONParser] def post(self, request): from jituan.models import ClubMigrateToken token = str(request.data.get('token') or '').strip() scene = str(request.data.get('scene') or '').strip() if not token and scene: token = migrate_svc.parse_migrate_scene(scene) if not token: return Response({'code': 400, 'msg': '缺少令牌'}) row = ClubMigrateToken.query.filter(token=token).first() if not row: return Response({'code': 400, 'msg': '令牌无效'}) plan = ClubMigratePlan.query.filter(plan_id=row.plan_id).first() return Response({ 'code': 0, 'msg': 'ok', 'data': { 'token': migrate_svc.serialize_token(row), 'plan': migrate_svc.serialize_plan(plan) if plan else None, 'can_confirm': row.status == ClubMigrateToken.STATUS_ISSUED, }, }) # ==================== 客服后台 ==================== class ClubMigrateAdminView(APIView): """ POST /jituan/houtai/club-migrate action: catalog | list | get | save | set_enabled | set_quota | ledger_list | ledger_detail """ permission_classes = [IsAuthenticated] parser_classes = [JSONParser] def post(self, request): kefu, permissions, err = _auth_group_admin(request) if err: return err action = (request.data.get('action') or 'list').strip().lower() operator = str(getattr(request.user, 'UserUID', '') or '') if action == 'catalog': return Response({'code': 0, 'msg': 'ok', 'data': {'assets': catalog_for_admin()}}) if action == 'list': qs = ClubMigratePlan.query.all().order_by('-id') from_club = (request.data.get('from_club_id') or '').strip() to_club = (request.data.get('to_club_id') or '').strip() if from_club: qs = qs.filter(from_club_id=from_club) if to_club: qs = qs.filter(to_club_id=to_club) return Response({ 'code': 0, 'msg': 'ok', 'data': {'list': [migrate_svc.serialize_plan(p) for p in qs[:200]]}, }) if action == 'get': plan_id = (request.data.get('plan_id') or '').strip() plan = ClubMigratePlan.query.filter(plan_id=plan_id).first() if not plan: return Response({'code': 404, 'msg': '计划不存在'}) return Response({'code': 0, 'msg': 'ok', 'data': migrate_svc.serialize_plan(plan)}) if action == 'save': return self._save_plan(request, operator) if action == 'set_enabled': plan_id = (request.data.get('plan_id') or '').strip() plan = ClubMigratePlan.query.filter(plan_id=plan_id).first() if not plan: return Response({'code': 404, 'msg': '计划不存在'}) enabled = request.data.get('enabled') plan.enabled = bool(enabled in (True, 1, '1', 'true', 'True')) plan.updated_by = operator plan.save(update_fields=['enabled', 'updated_by', 'UpdateTime']) return Response({'code': 0, 'msg': '已更新', 'data': migrate_svc.serialize_plan(plan)}) if action == 'set_quota': plan_id = (request.data.get('plan_id') or '').strip() from_uid = (request.data.get('from_uid') or '').strip() if not plan_id or not from_uid: return Response({'code': 400, 'msg': '缺少 plan_id/from_uid'}) plan = ClubMigratePlan.query.filter(plan_id=plan_id).first() if not plan: return Response({'code': 404, 'msg': '计划不存在'}) try: extra = int(request.data.get('max_extra_times') or 1) except (TypeError, ValueError): return Response({'code': 400, 'msg': 'max_extra_times 无效'}) user_info = migrate_svc.lookup_migrate_user_brief(from_uid) if not user_info.get('exists'): return Response({'code': 404, 'msg': f'用户不存在:{from_uid}'}) row, _ = ClubMigrateUserQuota.query.get_or_create( plan_id=plan_id, from_uid=from_uid, defaults={'max_extra_times': max(0, extra), 'updated_by': operator}, ) row.max_extra_times = max(0, extra) row.reason = str(request.data.get('reason') or '')[:255] row.updated_by = operator row.save(update_fields=['max_extra_times', 'reason', 'updated_by', 'UpdateTime']) return Response({ 'code': 0, 'msg': '已设置二次名额', 'data': migrate_svc.serialize_user_quota(row, plan=plan, user_info=user_info), }) if action == 'list_quota': plan_id = (request.data.get('plan_id') or '').strip() if not plan_id: return Response({'code': 400, 'msg': '缺少 plan_id'}) plan = ClubMigratePlan.query.filter(plan_id=plan_id).first() if not plan: return Response({'code': 404, 'msg': '计划不存在'}) rows = list( ClubMigrateUserQuota.query.filter(plan_id=plan_id).order_by('-id')[:200] ) return Response({ 'code': 0, 'msg': 'ok', 'data': { 'list': [ migrate_svc.serialize_user_quota(r, plan=plan) for r in rows ], }, }) if action == 'ledger_list': return self._ledger_list(request) if action == 'ledger_detail': try: lid = int(request.data.get('ledger_id') or 0) except (TypeError, ValueError): return Response({'code': 400, 'msg': 'ledger_id 无效'}) led = ClubMigrateLedger.query.filter(id=lid).first() if not led: return Response({'code': 404, 'msg': '流水不存在'}) return Response({'code': 0, 'msg': 'ok', 'data': migrate_svc.serialize_ledger(led)}) return Response({'code': 400, 'msg': '无效 action'}) def _save_plan(self, request, operator: str): data = request.data plan_id = (data.get('plan_id') or '').strip() from_club = (data.get('from_club_id') or '').strip() to_club = (data.get('to_club_id') or '').strip() if not from_club or not to_club: return Response({'code': 400, 'msg': '缺少 from_club_id/to_club_id'}) if from_club == to_club: return Response({'code': 400, 'msg': '源与目标俱乐部不能相同'}) whitelist = normalize_whitelist(data.get('asset_whitelist') or []) if not whitelist: return Response({'code': 400, 'msg': '请至少勾选一项资产白名单'}) force_roles = data.get('force_roles') or ['dashou'] if not isinstance(force_roles, list): force_roles = ['dashou'] condition = data.get('condition_json') or {} if not isinstance(condition, dict): condition = {} if plan_id: plan = ClubMigratePlan.query.filter(plan_id=plan_id).first() if not plan: return Response({'code': 404, 'msg': '计划不存在'}) else: plan = ClubMigratePlan( plan_id=migrate_svc.new_plan_id(), from_club_id=from_club, to_club_id=to_club, ) plan.name = str(data.get('name') or '')[:64] plan.from_club_id = from_club plan.to_club_id = to_club if 'enabled' in data: plan.enabled = bool(data.get('enabled') in (True, 1, '1', 'true', 'True')) plan.start_at = _parse_dt(data.get('start_at')) plan.end_at = _parse_dt(data.get('end_at')) plan.asset_whitelist = whitelist plan.condition_json = condition if 'force_intercept' in data: plan.force_intercept = bool(data.get('force_intercept') in (True, 1, '1', 'true', 'True')) plan.force_roles = force_roles if 'default_once_per_user' in data: plan.default_once_per_user = bool( data.get('default_once_per_user') in (True, 1, '1', 'true', 'True') ) plan.remark = str(data.get('remark') or '')[:255] plan.updated_by = operator plan.save() return Response({'code': 0, 'msg': '已保存', 'data': migrate_svc.serialize_plan(plan)}) def _ledger_list(self, request): qs = ClubMigrateLedger.query.all().order_by('-id') plan_id = (request.data.get('plan_id') or '').strip() uid = (request.data.get('uid') or '').strip() from_club = (request.data.get('from_club_id') or '').strip() to_club = (request.data.get('to_club_id') or '').strip() if plan_id: qs = qs.filter(plan_id=plan_id) if uid: qs = qs.filter(Q(from_uid=uid) | Q(to_uid=uid)) if from_club: qs = qs.filter(from_club_id=from_club) if to_club: qs = qs.filter(to_club_id=to_club) try: page = max(1, int(request.data.get('page') or 1)) page_size = min(100, max(1, int(request.data.get('page_size') or 20))) except (TypeError, ValueError): page, page_size = 1, 20 total = qs.count() start = (page - 1) * page_size rows = list(qs[start:start + page_size]) return Response({ 'code': 0, 'msg': 'ok', 'data': { 'total': total, 'page': page, 'page_size': page_size, 'list': [migrate_svc.serialize_ledger(x) for x in rows], }, })