Files
Django/jituan/views_club_migrate.py

483 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""跨俱乐部资产迁移 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'})
try:
extra = int(request.data.get('max_extra_times') or 1)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': 'max_extra_times 无效'})
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': {
'plan_id': plan_id,
'from_uid': from_uid,
'max_extra_times': row.max_extra_times,
},
})
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],
},
})