506 lines
20 KiB
Python
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],
|
||
},
|
||
})
|