Files
Django/jituan/views_club_migrate.py
T

506 lines
20 KiB
Python

"""跨俱乐部资产迁移 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],
},
})