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],
|
|
},
|
|
})
|