"""跨俱乐部资产迁移核心服务(独立模块,未开计划时零影响)。 P0:所有成功过户必须写 Ledger + Item;金额以锁库实读为准;令牌单次消费;占用表防双迁。 """ from __future__ import annotations import hashlib import logging import secrets import uuid from datetime import datetime, timedelta from decimal import Decimal, ROUND_HALF_UP from typing import Dict, List, Optional, Tuple from django.db import transaction from django.db.models import F, Q from django.utils import timezone from jituan.models import ( ClubMigrateConsumption, ClubMigrateLedger, ClubMigrateLedgerItem, ClubMigratePlan, ClubMigrateToken, ClubMigrateUserQuota, ) from jituan.services.club_migrate_assets import ( ASSET_CATALOG, ROLE_PROFILE_ATTR, normalize_whitelist, roles_from_whitelist, ) logger = logging.getLogger(__name__) TOKEN_TTL_HOURS = 24 PASSWORD_MAX_FAIL = 5 SCENE_PREFIX = 'MIG' # 未完结订单:待抢/进行中/退款审/指定中/结算中/未支付 OPEN_ORDER_STATUSES = (1, 2, 4, 7, 8, 9) # 未缴清罚单:待缴纳/申诉中/平台审核中 UNPAID_PENALTY_STATUSES = (1, 3, 5) # 目标店新建打手时绑定的默认管事邀请码(与小程序 PLATFORM 默认码一致) CLUB_MIGRATE_DEFAULT_GUANSHI_INVITE = { 'lxs': '0000008VfJKPu4Spmcjt', 'xq': '0000008RffVgKHMj7kQC', } # 可提现类字段:迁入走目标店冻结网关(押金/积分/已冻结池除外) _FREEZE_CREDIT_FIELDS = frozenset({ ('dashou', 'yue'), ('guanshi', 'yue'), ('zuzhang', 'ketixian_jine'), ('shenheguan', 'yue'), }) MEMBER_JIFEN_CAP = 10 def _money(v) -> Decimal: return Decimal(str(v or 0)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) def _now(): return timezone.now() def _hash_password(password: str, salt: str) -> str: raw = f'{salt}:{password}'.encode('utf-8') return hashlib.sha256(raw).hexdigest() def new_plan_id() -> str: return 'mp' + uuid.uuid4().hex[:14] def new_token() -> str: # 短令牌便于塞进微信 scene(总长 32) return secrets.token_urlsafe(12).replace('-', '').replace('_', '')[:20] def scene_for_token(token: str) -> str: return f'{SCENE_PREFIX}{token}' def parse_migrate_scene(scene: str) -> str: s = str(scene or '').strip() if s.upper().startswith(SCENE_PREFIX): return s[len(SCENE_PREFIX):] return '' def plan_in_window(plan: ClubMigratePlan, now=None) -> bool: now = now or _now() if plan.start_at and now < plan.start_at: return False if plan.end_at and now > plan.end_at: return False return True def find_active_plan_for_source(from_club_id: str) -> Optional[ClubMigratePlan]: cid = (from_club_id or '').strip() if not cid: return None qs = ClubMigratePlan.query.filter(from_club_id=cid, enabled=True).order_by('-id') now = _now() for p in qs[:20]: if plan_in_window(p, now): return p return None def find_active_plan_for_target(to_club_id: str) -> Optional[ClubMigratePlan]: cid = (to_club_id or '').strip() if not cid: return None qs = ClubMigratePlan.query.filter(to_club_id=cid, enabled=True).order_by('-id') now = _now() for p in qs[:20]: if plan_in_window(p, now): return p return None def max_allowed_success(plan: ClubMigratePlan, from_uid: str) -> int: base = 1 if plan.default_once_per_user else 999 q = ClubMigrateUserQuota.query.filter(plan_id=plan.plan_id, from_uid=str(from_uid)).first() extra = int(q.max_extra_times or 0) if q else 0 return base + extra def success_count(plan_id: str, from_uid: str) -> int: row = ClubMigrateConsumption.query.filter(plan_id=plan_id, from_uid=str(from_uid)).first() return int(row.success_count or 0) if row else 0 def can_migrate_again(plan: ClubMigratePlan, from_uid: str) -> bool: """有剩余次数,或上次未迁干净(源店仍有可迁资产)时可再迁。""" if success_count(plan.plan_id, from_uid) < max_allowed_success(plan, from_uid): return True # 次数已用尽但仍有资产(如会员曾被 skip 留下)→ 允许补迁,不依赖二次名额 return user_has_migratable_assets(plan, from_uid) def _load_user(uid: str): from users.business_models import User return User.query.filter(UserUID=str(uid)).first() def _resolve_default_guanshi_uid(club_id: str) -> Optional[str]: """按俱乐部硬编码邀请码解析默认管事 UID;解析失败返回 None。""" code = CLUB_MIGRATE_DEFAULT_GUANSHI_INVITE.get((club_id or '').strip()) if not code: return None from users.models import UserGuanshi gs = UserGuanshi.query.filter(yaoqingma=code).select_related('user').first() if not gs or not getattr(gs, 'user', None): logger.warning('migrate default guanshi invite not found club=%s code=%s', club_id, code) return None return str(gs.user.UserUID) def _get_or_create_profile(user, role: str, *, to_club_id: str = ''): """目标店无角色时创建空扩展行(仅迁移模块调用)。 新建打手:绑定目标店默认管事(源店邀请人在新店无效)。 已有打手资料:不改 yaoqingren(曾在新店注册过的情况保留原绑定)。 """ from users.models import UserDashou, UserShangjia, UserGuanshi, UserZuzhang, UserShenheguan mapping = { 'dashou': (UserDashou, 'DashouProfile'), 'shangjia': (UserShangjia, 'ShopProfile'), 'guanshi': (UserGuanshi, 'GuanshiProfile'), 'zuzhang': (UserZuzhang, 'ZuzhangProfile'), 'shenheguan': (UserShenheguan, 'ShenheguanProfile'), } model, attr = mapping[role] try: return getattr(user, attr) except Exception: pass defaults = {'user': user} if role == 'shangjia': defaults['nicheng'] = f'SJ{user.UserUID}' elif role == 'dashou': defaults['nicheng'] = f'DS{user.UserUID}' gs_uid = _resolve_default_guanshi_uid(to_club_id) if gs_uid: defaults['yaoqingren'] = gs_uid obj = model.query.create(**defaults) return obj def _profile_of(user, role: str): attr = ROLE_PROFILE_ATTR.get(role) if not attr: return None try: return getattr(user, attr) except Exception: return None def _lock_profile(user, role: str): """事务内锁扩展行;无行返回 None。""" from users.models import UserDashou, UserShangjia, UserGuanshi, UserZuzhang, UserShenheguan mapping = { 'dashou': UserDashou, 'shangjia': UserShangjia, 'guanshi': UserGuanshi, 'zuzhang': UserZuzhang, 'shenheguan': UserShenheguan, } model = mapping.get(role) if not model: return None return model.objects.select_for_update().filter(user=user).first() def _read_field(profile, field: str, kind: str): if profile is None: return Decimal('0.00') if kind != 'int' else 0 val = getattr(profile, field, None) if kind == 'int': try: return int(val or 0) except (TypeError, ValueError): return 0 return _money(val) def preview_assets(plan: ClubMigratePlan, from_uid: str) -> List[dict]: keys = normalize_whitelist(plan.asset_whitelist) user = _load_user(from_uid) if not user: return [] items = [] for key in keys: meta = ASSET_CATALOG[key] role, field, kind = meta['role'], meta['field'], meta['kind'] if kind == 'huiyuan': items.extend(_preview_huiyuan(from_uid, plan.from_club_id, role)) continue profile = _profile_of(user, role) amount = _read_field(profile, field, kind) if kind == 'money': display = f'¥{amount}' elif kind == 'int': display = str(int(amount) if amount is not None else 0) else: display = str(amount) items.append({ 'key': key, 'role': role, 'field': field, 'label': meta['label'], 'kind': kind, 'amount': str(amount), 'display': display, 'has_profile': profile is not None, }) return items def asset_item_has_value(item: dict) -> bool: """预览项是否有可迁金额/积分/会员。""" kind = (item or {}).get('kind') or '' if kind == 'huiyuan': return True try: if kind == 'int': return int(item.get('amount') or 0) > 0 return _money(item.get('amount')) > 0 except (TypeError, ValueError): return False def user_has_migratable_assets(plan: ClubMigratePlan, from_uid: str) -> bool: """白名单内是否有任意可迁资产;全无则不应强制进迁移页。""" try: items = preview_assets(plan, from_uid) except Exception as e: logger.warning('migrate asset check failed uid=%s: %s', from_uid, e) return True # 检查失败时保守:仍允许进页,避免误放行有资产用户 return any(asset_item_has_value(it) for it in items) def _membership_core_key(huiyuan_id: str) -> str: from jituan.services.huiyuan_bundle import normalize_huiyuan_id hid = normalize_huiyuan_id(huiyuan_id) if not hid: return '' return hid.lstrip('0') or '0' def _club_row_belongs_to_source(rec_cid: str, from_club: str) -> bool: """会员/充值单 club_id 是否归属源店(历史默认 xq/空也算)。""" from jituan.constants import CLUB_ID_DEFAULT from_club = (from_club or '').strip() rec_cid = (rec_cid or '').strip() if not rec_cid or rec_cid in (from_club, CLUB_ID_DEFAULT, ''): return True return bool(from_club) and rec_cid == from_club def _collect_migratable_huiyuan(uid: str, from_club: str, *, for_update: bool = False) -> List[dict]: """ 可迁会员 = 用户端能看到的有效会员(开通表 + 未撤销充值单合成)。 第一次迁移若只弄坏了开通表、充值单还在,第二次仍能迁。 """ from jituan.constants import CLUB_ID_DEFAULT from jituan.services.huiyuan_bundle import normalize_huiyuan_id from jituan.services.member_recharge import ( MEMBER_LEIXING, MEMBER_PAID_STATUS, czjilu_entitlement_active, _czjilu_entitlement_end, _order_is_trial_like, ) from products.models import Czjilu, Huiyuan, Huiyuangoumai now = _now_aware() from_club = (from_club or '').strip() merged = {} # core_key -> entry def upsert(*, huiyuan_id, daoqi, jieshao='', is_trial=False, has_used_trial=False, goumai_row=None, source='goumai'): hid = normalize_huiyuan_id(huiyuan_id) key = _membership_core_key(hid) exp = _aware(daoqi) if not key or not exp or exp <= now: return cur = merged.get(key) if cur and _aware(cur['daoqi_time']) and _aware(cur['daoqi_time']) >= exp: # 已有更晚到期;仍补挂开通行便于失效 if goumai_row is not None and cur.get('goumai_row') is None: cur['goumai_row'] = goumai_row return merged[key] = { 'huiyuan_id': hid, 'daoqi_time': exp, 'jieshao': (jieshao or '').strip(), 'is_trial': bool(is_trial), 'has_used_trial': bool(has_used_trial), 'goumai_row': goumai_row, 'source': source, 'record_club_id': (getattr(goumai_row, 'club_id', None) or '') if goumai_row else '', } qs = Huiyuangoumai.objects.filter(yonghu_id=str(uid)) if for_update: qs = qs.select_for_update() for r in qs: if not _club_row_belongs_to_source(getattr(r, 'club_id', None) or '', from_club): continue # 开通表有效才直接进;失效的留给充值单补 if int(r.huiyuan_zhuangtai or 0) != 1: continue upsert( huiyuan_id=r.huiyuan_id, daoqi=r.daoqi_time, jieshao=r.jieshao or '', is_trial=bool(r.is_trial), has_used_trial=bool(r.has_used_trial), goumai_row=r, source='goumai', ) for order in Czjilu.query.filter( yonghuid=str(uid), leixing=MEMBER_LEIXING, zhuangtai=MEMBER_PAID_STATUS, ).order_by('-CreateTime')[:50]: hid = normalize_huiyuan_id(order.huiyuan_id) if not hid: continue order_cid = (getattr(order, 'club_id', None) or '').strip() if not _club_row_belongs_to_source(order_cid, from_club): continue cid = order_cid or from_club or CLUB_ID_DEFAULT if not czjilu_entitlement_active(order, cid): continue end = _czjilu_entitlement_end(order, cid) name = '' hy = Huiyuan.query.filter(huiyuan_id=hid).first() if hy: name = (hy.jieshao or '').strip() # 若开通表已有同行但状态失效,挂上便于迁完统一置到期 goumai = None key = _membership_core_key(hid) if key not in merged or merged[key].get('goumai_row') is None: goumai = _find_user_huiyuangoumai(uid, hid, for_update=for_update) if goumai and not _club_row_belongs_to_source(getattr(goumai, 'club_id', None) or '', from_club): goumai = None upsert( huiyuan_id=hid, daoqi=end, jieshao=name, is_trial=bool(_order_is_trial_like(order, cid)), has_used_trial=False, goumai_row=goumai, source='czjilu', ) return list(merged.values()) def _list_active_huiyuan_rows(uid: str, from_club: str, *, for_update: bool = False): """兼容旧调用:仅返回开通表有效行。迁移请用 _collect_migratable_huiyuan。""" from products.models import Huiyuangoumai now = _now_aware() from_club = (from_club or '').strip() qs = Huiyuangoumai.objects.filter( yonghu_id=str(uid), huiyuan_zhuangtai=1, ) if for_update: qs = qs.select_for_update() out = [] for r in qs: exp = _aware(r.daoqi_time) if not exp or exp <= now: continue if not _club_row_belongs_to_source(getattr(r, 'club_id', None) or '', from_club): continue out.append(r) return out def _preview_huiyuan(uid: str, club_id: str, role: str) -> List[dict]: from products.models import Huiyuan entries = _collect_migratable_huiyuan(uid, club_id) if not entries: return [] ids = [e['huiyuan_id'] for e in entries] name_map = { h.huiyuan_id: (h.jieshao or '').strip() or '会员' for h in Huiyuan.query.filter(huiyuan_id__in=ids) } out = [] for e in entries: name = e.get('jieshao') or name_map.get(e['huiyuan_id']) or '会员' exp = _aware(e.get('daoqi_time')) if exp is None: exp_text = '' elif timezone.is_aware(exp): exp_text = timezone.localtime(exp).strftime('%Y-%m-%d %H:%M') else: exp_text = exp.strftime('%Y-%m-%d %H:%M') src_tag = '开通' if e.get('source') == 'goumai' else '充值权益' out.append({ 'key': f'{role}.huiyuan:{e["huiyuan_id"]}', 'role': role, 'field': 'huiyuan', 'label': name, 'kind': 'huiyuan', 'amount': exp_text, 'display': f'{name} · 到期 {exp_text}' if exp_text else name, 'huiyuan_id': e['huiyuan_id'], 'daoqi_time': exp.isoformat() if exp else '', 'jieshao': name, 'has_profile': True, 'record_club_id': e.get('record_club_id') or '', 'source': e.get('source') or '', 'source_tag': src_tag, }) return out def _resolve_migrate_target_huiyuan_ids( *, src_huiyuan_id: str, from_club: str = '', to_club: str = '', ) -> List[str]: """ 普通会员:原样迁同 ID。 总会员:按子会员格式迁出(写入各子会员开通记录),不在目标店再挂一条总会员。 未配置子项时才退回迁总会员本身,避免丢权益。 """ from jituan.services.huiyuan_bundle import ( is_bundle_huiyuan, normalize_huiyuan_id, resolve_bundle_included_ids, ) src_id = normalize_huiyuan_id(src_huiyuan_id) if not src_id: return [] if not is_bundle_huiyuan(src_id): return [src_id] kids = [] seen = set() for cid in (from_club, to_club): for hid in resolve_bundle_included_ids( src_id, club_id=(cid or '').strip() or None, record_club_id=(from_club or '').strip() or None, ) or []: kid = normalize_huiyuan_id(hid) if not kid: continue core = kid.lstrip('0') or '0' if core in seen: continue seen.add(core) kids.append(kid) if kids: break if kids: return kids return [src_id] def _revoke_source_member_czjilu(from_uid: str, huiyuan_id: str): """撤销源店充值单权益,避免 Huiyuangoumai 失效后仍被 clumber 从 Czjilu 合成展示。""" from jituan.services.huiyuan_bundle import huiyuan_ids_match from jituan.services.member_recharge import ( ENTITLEMENT_REVOKED_MARKER, MEMBER_LEIXING, MEMBER_PAID_STATUS, _czjilu_entitlement_revoked, _truncate_shuoming, ) from products.models import Czjilu for order in Czjilu.query.filter( yonghuid=str(from_uid), leixing=MEMBER_LEIXING, zhuangtai=MEMBER_PAID_STATUS, ): if not huiyuan_ids_match(order.huiyuan_id, huiyuan_id): continue if _czjilu_entitlement_revoked(order): continue note = (order.shuoming or '').strip() order.shuoming = _truncate_shuoming( f'{note} |跨店迁移|{ENTITLEMENT_REVOKED_MARKER}|' if note else f'跨店迁移|{ENTITLEMENT_REVOKED_MARKER}' ) order.save(update_fields=['shuoming']) def _expire_source_huiyuan(row, now): """源会员置失效。""" row.huiyuan_zhuangtai = 0 row.daoqi_time = now row.save(update_fields=['huiyuan_zhuangtai', 'daoqi_time', 'UpdateTime']) try: _revoke_source_member_czjilu(row.yonghu_id, row.huiyuan_id) except Exception as e: logger.warning( 'migrate revoke czjilu fail uid=%s hy=%s: %s', row.yonghu_id, row.huiyuan_id, e, ) def _expire_source_membership(*, from_uid: str, huiyuan_id: str, goumai_row=None): """迁完后清掉源店开通记录 + 充值单合成权益。""" now = _now_aware() row = goumai_row if row is None: row = _find_user_huiyuangoumai(from_uid, huiyuan_id, for_update=True) if row is not None: row.huiyuan_zhuangtai = 0 row.daoqi_time = now row.save(update_fields=['huiyuan_zhuangtai', 'daoqi_time', 'UpdateTime']) try: _revoke_source_member_czjilu(from_uid, huiyuan_id) except Exception as e: logger.warning( 'migrate revoke czjilu fail uid=%s hy=%s: %s', from_uid, huiyuan_id, e, ) def _find_user_huiyuangoumai(uid: str, huiyuan_id: str, *, for_update: bool = False): """按会员 ID 变体查找购买记录(001 / 1 等同)。""" from jituan.services.huiyuan_bundle import expand_huiyuan_id_variants from products.models import Huiyuangoumai variants = expand_huiyuan_id_variants(huiyuan_id) or [str(huiyuan_id)] qs = Huiyuangoumai.objects.filter( yonghu_id=str(uid), huiyuan_id__in=variants, ) if for_update: qs = qs.select_for_update() return qs.first() def _latest_effective_cover_until(uid: str, huiyuan_id: str, club_id: str, *, goumai_row=None): """ 目标店对该会员类型的有效覆盖到期: - 自己开通的该子会员/总会员未到期 - 或任意未到期总会员包含该子会员 用于迁移累加:总会员覆盖剩 10 天 + 迁入同子会员 10 天 → 子会员落到 20 天。 """ from jituan.services.huiyuan_bundle import ( _included_huiyuan_id_set, _membership_record_active, expand_huiyuan_id_variants, huiyuan_ids_match, is_bundle_huiyuan, normalize_huiyuan_id, resolve_bundle_included_ids, ) from products.models import Huiyuangoumai now = _now_aware() hid = normalize_huiyuan_id(huiyuan_id) if not hid: return None req_set = {hid, hid.lstrip('0') or '0'} for vid in expand_huiyuan_id_variants(hid): req_set.add(vid) req_set.add(vid.lstrip('0') or '0') latest = None def bump(dt): nonlocal latest exp = _aware(dt) if not exp or exp <= now: return if latest is None or exp > latest: latest = exp row = goumai_row if row is None: row = _find_user_huiyuangoumai(uid, hid) if row is not None and int(row.huiyuan_zhuangtai or 0) == 1: bump(row.daoqi_time) for rec in Huiyuangoumai.query.filter(yonghu_id=str(uid)): if not _membership_record_active(rec): continue if huiyuan_ids_match(rec.huiyuan_id, hid): bump(rec.daoqi_time) continue if not is_bundle_huiyuan(rec.huiyuan_id): continue included = resolve_bundle_included_ids( rec.huiyuan_id, club_id=club_id, yonghu_id=uid, record_club_id=getattr(rec, 'club_id', None), ) if req_set & _included_huiyuan_id_set(included): bump(rec.daoqi_time) return latest def _add_huiyuan_duration(*, to_uid: str, to_club: str, huiyuan_id: str, remain, jieshao: str, is_trial: bool, has_used_trial: bool): """给目标用户对应会员加时长。 累加基数 = max(现在, 该会员自身到期, 覆盖它的总会员到期)。 避免:目标已有总会员覆盖子会员 10 天,迁入同子会员 10 天却只得到约 10 天(应为 20)。 """ from products.models import Huiyuangoumai now = _now_aware() remain = remain if remain and remain.total_seconds() > 0 else None if remain is None: return None, {} hid = str(huiyuan_id).strip() dst = _find_user_huiyuangoumai(to_uid, hid, for_update=True) before_dst = {} if dst: before_dst = { 'daoqi_time': dst.daoqi_time.isoformat() if dst.daoqi_time else '', 'zhuangtai': dst.huiyuan_zhuangtai, 'club_id': dst.club_id, } cover = _latest_effective_cover_until(to_uid, hid, to_club, goumai_row=dst) base = cover if (cover and cover > now) else now exp = base + remain if dst: dst.daoqi_time = exp dst.huiyuan_zhuangtai = 1 dst.club_id = to_club if jieshao: dst.jieshao = jieshao dst.save(update_fields=['daoqi_time', 'huiyuan_zhuangtai', 'club_id', 'jieshao', 'UpdateTime']) return exp, before_dst try: Huiyuangoumai.query.create( huiyuan_id=hid, yonghu_id=str(to_uid), huiyuan_zhuangtai=1, jieshao=jieshao or '', daoqi_time=exp, club_id=to_club, is_trial=bool(is_trial), has_used_trial=bool(has_used_trial), ) except Exception: # 并发/ID 变体撞唯一约束:再查一次改成累加 dst2 = _find_user_huiyuangoumai(to_uid, hid, for_update=True) if not dst2: raise before_dst = { 'daoqi_time': dst2.daoqi_time.isoformat() if dst2.daoqi_time else '', 'zhuangtai': dst2.huiyuan_zhuangtai, 'club_id': dst2.club_id, } cover2 = _latest_effective_cover_until(to_uid, hid, to_club, goumai_row=dst2) base2 = cover2 if (cover2 and cover2 > now) else now exp = base2 + remain dst2.daoqi_time = exp dst2.huiyuan_zhuangtai = 1 dst2.club_id = to_club if jieshao: dst2.jieshao = jieshao dst2.save(update_fields=['daoqi_time', 'huiyuan_zhuangtai', 'club_id', 'jieshao', 'UpdateTime']) return exp, before_dst return exp, before_dst def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dict]: """ 未到期会员:目标店加同等剩余时长;源店置已到期并撤销充值权益。 总会员按子会员拆分写入(不用总会员 ID 在目标店再挂一条)。 """ from products.models import Huiyuan from jituan.services.huiyuan_bundle import is_bundle_huiyuan, normalize_huiyuan_id now = _now_aware() entries = _collect_migratable_huiyuan(from_uid, from_club, for_update=True) out = [] # 同一迁移内按子会员去重,避免源店「总会员+子会员」重复加时长 transferred_cores = set() for e in entries: src_exp = _aware(e.get('daoqi_time')) name = (e.get('jieshao') or '').strip() if not name: hy = Huiyuan.query.filter(huiyuan_id=e['huiyuan_id']).first() name = (hy.jieshao if hy else '') or '会员' before = { 'huiyuan_id': e['huiyuan_id'], 'daoqi_time': src_exp.isoformat() if src_exp else '', 'zhuangtai': 1, 'is_trial': bool(e.get('is_trial')), 'jieshao': name, 'record_club_id': e.get('record_club_id') or '', 'source': e.get('source') or '', } remain = (src_exp - now) if src_exp and src_exp > now else None if remain is None: continue target_ids = _resolve_migrate_target_huiyuan_ids( src_huiyuan_id=e['huiyuan_id'], from_club=from_club, to_club=to_club, ) if not target_ids: raise ValueError(f'会员 ID 无效,无法迁移: {e["huiyuan_id"]}') formal_floor = 0 if not bool(e.get('is_trial')): from jituan.services.member_recharge import count_formal_paid_member_orders src_paid = count_formal_paid_member_orders( from_uid, e['huiyuan_id'], from_club, ) formal_floor = max(int(src_paid or 0), 1) wrote_any = False src_is_bundle = is_bundle_huiyuan(e['huiyuan_id']) for tid in target_ids: tid = normalize_huiyuan_id(tid) or tid core = (tid.lstrip('0') or '0') if tid else '' if core and core in transferred_cores: continue child_name = name if src_is_bundle or tid != normalize_huiyuan_id(e['huiyuan_id']): chy = Huiyuan.query.filter(huiyuan_id=tid).first() if not chy: from jituan.services.huiyuan_bundle import expand_huiyuan_id_variants for vid in expand_huiyuan_id_variants(tid): chy = Huiyuan.query.filter(huiyuan_id=vid).first() if chy: break child_name = ((chy.jieshao if chy else '') or '').strip() or tid exp, before_dst = _add_huiyuan_duration( to_uid=to_uid, to_club=to_club, huiyuan_id=tid, remain=remain, jieshao=child_name, is_trial=bool(e.get('is_trial')), has_used_trial=bool(e.get('has_used_trial')), ) if exp is None: raise ValueError(f'会员过户失败(加时长): {child_name}({tid})') if core: transferred_cores.add(core) wrote_any = True out.append({ 'item_type': ClubMigrateLedgerItem.ITEM_HUIYUAN, 'role': role, 'field': 'huiyuan', 'amount': Decimal('1'), 'before_src': before.get('daoqi_time', ''), 'after_src': 'expired', 'before_dst': before_dst.get('daoqi_time', ''), 'after_dst': exp.isoformat() if exp else '', 'huiyuan_id': tid, 'meta_json': { 'before_src': before, 'before_dst': before_dst, 'src_huiyuan_id': e['huiyuan_id'], 'name': child_name, 'mapped_from_bundle': bool(src_is_bundle and tid != normalize_huiyuan_id(e['huiyuan_id'])), 'formal_purchase_floor': formal_floor, 'is_trial': bool(e.get('is_trial')), 'source': e.get('source') or '', }, }) if not wrote_any: raise ValueError(f'会员过户失败: {name}({e["huiyuan_id"]})') _expire_source_membership( from_uid=from_uid, huiyuan_id=e['huiyuan_id'], goumai_row=e.get('goumai_row'), ) return out def _aware(dt): """与 timezone.now() 对齐 aware/naive,避免混比炸 500。""" if dt is None: return None now_sample = timezone.now() if timezone.is_aware(now_sample): if timezone.is_naive(dt): return timezone.make_aware(dt, timezone.get_current_timezone()) return dt # 项目未开 USE_TZ:一律 naive if timezone.is_aware(dt): return timezone.make_naive(dt, timezone.get_current_timezone()) return dt def _now_aware(): """业务比较用的当前时间(与 _aware 同一套时区规则)。""" return _aware(_now()) or _now() def check_conditions(plan: ClubMigratePlan, from_uid: str) -> Tuple[bool, str]: """返回 (ok, msg)。""" cond = plan.condition_json or {} user = _load_user(from_uid) if not user: return False, '源用户不存在' if cond.get('require_not_banned', True): ds = _profile_of(user, 'dashou') if ds is not None and int(getattr(ds, 'zhanghaozhuangtai', 1) or 1) != 1: return False, '账号已封禁,无法迁移' if cond.get('require_no_unpaid_penalty'): try: from orders.models import Penalty unpaid = Penalty.query.filter( PenalizedUserID=str(from_uid), ClubID=plan.from_club_id, Status__in=list(UNPAID_PENALTY_STATUSES), ).exists() if unpaid: return False, '存在未缴罚单,请先缴清' except Exception as e: logger.warning('migrate penalty check failed uid=%s: %s', from_uid, e) return False, '罚单门槛校验失败,请稍后重试' if cond.get('require_orders_closed'): try: from orders.models import Order open_st = Order.objects.filter( PlayerID=str(from_uid), ClubID=plan.from_club_id, Status__in=list(OPEN_ORDER_STATUSES), ).exists() if open_st: return False, '仍有未完结订单,请先结算或退款完成' except Exception as e: logger.warning('migrate order check failed uid=%s: %s', from_uid, e) return False, '订单门槛校验失败,请稍后重试' return True, '' def gate_for_user(*, club_id: str, user_uid: str, roles: Optional[List[str]] = None) -> dict: """小程序启动/进打手端调用。无计划 → 全 false,现网无感。""" plan = find_active_plan_for_source(club_id) if not plan: target = find_active_plan_for_target(club_id) return { 'active': False, 'force': False, 'show_entry': False, 'completed': False, 'plan_id': '', 'to_club_id': '', 'is_target': bool(target), 'target_from_club_id': (target.from_club_id if target else ''), } done = not can_migrate_again(plan, user_uid) force_roles = plan.force_roles or ['dashou'] role_hit = True if roles: role_hit = any(r in force_roles for r in roles) has_assets = user_has_migratable_assets(plan, user_uid) # 关后台「启用」→ find_active 找不到计划 → 上面已全 false,前端零感知 # 开「启用」:源店展示入口(与有无资产无关);「强制拦截」只控制自动跳 force = bool(plan.force_intercept and role_hit and not done and has_assets) show_entry = True return { 'active': True, 'force': force, 'show_entry': show_entry, 'completed': done, 'has_assets': has_assets, 'plan_id': plan.plan_id, 'plan_name': plan.name or '', 'from_club_id': plan.from_club_id, 'to_club_id': plan.to_club_id, 'force_roles': force_roles, 'force_intercept': bool(plan.force_intercept), 'success_count': success_count(plan.plan_id, user_uid), 'max_allowed': max_allowed_success(plan, user_uid), 'is_target': False, } @transaction.atomic def set_password_and_issue( *, plan: ClubMigratePlan, from_uid: str, password: str, qrcode_url: str = '', ) -> Tuple[Optional[ClubMigrateToken], str]: pwd = str(password or '').strip() if not (pwd.isdigit() and len(pwd) == 6): return None, '转移密码须为6位数字' if not can_migrate_again(plan, from_uid): return None, '本计划已迁移完成,不可再次发码' ok, msg = check_conditions(plan, from_uid) if not ok: return None, msg ClubMigrateToken.query.filter( plan_id=plan.plan_id, from_uid=str(from_uid), status=ClubMigrateToken.STATUS_ISSUED, ).update(status=ClubMigrateToken.STATUS_CANCELLED, UpdateTime=_now()) salt = secrets.token_hex(8) token = new_token() snap = {'assets': preview_assets(plan, from_uid), 'at': _now().isoformat()} row = ClubMigrateToken.query.create( token=token, plan_id=plan.plan_id, from_club_id=plan.from_club_id, to_club_id=plan.to_club_id, from_uid=str(from_uid), password_hash=_hash_password(pwd, salt), password_salt=salt, status=ClubMigrateToken.STATUS_ISSUED, asset_snapshot=snap, qrcode_url=(qrcode_url or '')[:512], expire_at=_now() + timedelta(hours=TOKEN_TTL_HOURS), ) return row, '' def verify_token_password(token_row: ClubMigrateToken, password: str) -> Tuple[bool, str]: if token_row.status != ClubMigrateToken.STATUS_ISSUED: return False, '令牌已失效' if token_row.expire_at and _now() > token_row.expire_at: token_row.status = ClubMigrateToken.STATUS_EXPIRED token_row.save(update_fields=['status', 'UpdateTime']) return False, '令牌已过期,请回源店重新设密出码' if int(token_row.fail_count or 0) >= PASSWORD_MAX_FAIL: return False, '密码错误次数过多,请回源店重置密码' pwd = str(password or '').strip() expect = _hash_password(pwd, token_row.password_salt) if expect != token_row.password_hash: ClubMigrateToken.query.filter(pk=token_row.pk).update(fail_count=F('fail_count') + 1) left = PASSWORD_MAX_FAIL - int(token_row.fail_count or 0) - 1 return False, f'转移密码错误(还可试{max(left, 0)}次)' return True, '' @transaction.atomic def confirm_migrate( *, token: str, password: str, to_uid: str, client_meta: Optional[dict] = None, ) -> Tuple[Optional[dict], str]: """目标店确认过户。成功返回 ledger 摘要。""" token_row = ( ClubMigrateToken.query.select_for_update() .filter(token=str(token or '').strip()) .first() ) if not token_row: return None, '令牌无效' if token_row.status == ClubMigrateToken.STATUS_CONSUMED: led = ClubMigrateLedger.query.filter(token=token_row.token).order_by('-id').first() if led: return serialize_ledger(led), '' return None, '令牌已使用' ok, msg = verify_token_password(token_row, password) if not ok: return None, msg plan = ClubMigratePlan.query.filter(plan_id=token_row.plan_id).first() if not plan or not plan.enabled or not plan_in_window(plan): return None, '迁移计划未生效' if not can_migrate_again(plan, token_row.from_uid): return None, '本计划已迁移完成' ok, msg = check_conditions(plan, token_row.from_uid) if not ok: return None, msg to_user = _load_user(to_uid) if not to_user: return None, '目标用户不存在,请先登录' from_user = _load_user(token_row.from_uid) if not from_user: return None, '源用户不存在' ds = _lock_profile(to_user, 'dashou') if ds is not None and int(getattr(ds, 'zhanghaozhuangtai', 1) or 1) != 1: return None, '目标账号已封禁' cons, _ = ClubMigrateConsumption.query.get_or_create( plan_id=plan.plan_id, from_uid=str(token_row.from_uid), defaults={'success_count': 0}, ) cons = ClubMigrateConsumption.query.select_for_update().filter(pk=cons.pk).first() if not can_migrate_again(plan, token_row.from_uid): return None, '本计划已迁移完成' keys = normalize_whitelist(plan.asset_whitelist) # 目标店:白名单涉及角色先建空扩展行 for role in roles_from_whitelist(keys): if _profile_of(to_user, role) is None: _get_or_create_profile(to_user, role, to_club_id=plan.to_club_id) items_payload = [] migrate_seq = int(cons.success_count or 0) + 1 freeze_biz_prefix = f'cm:{token_row.token}:{migrate_seq}' try: for key in keys: meta = ASSET_CATALOG[key] if meta['kind'] == 'huiyuan': items_payload.extend( _transfer_huiyuan( from_uid=token_row.from_uid, to_uid=str(to_uid), from_club=plan.from_club_id, to_club=plan.to_club_id, role=meta['role'], ) ) continue items_payload.append( _transfer_balance_field( from_user=from_user, to_user=to_user, role=meta['role'], field=meta['field'], kind=meta['kind'], key=key, label=meta['label'], to_club_id=plan.to_club_id, freeze_biz_id=f'{freeze_biz_prefix}:{key}'[:64], ) ) except ValueError as e: transaction.set_rollback(True) return None, str(e) or '会员过户失败' ledger = ClubMigrateLedger.query.create( plan_id=plan.plan_id, token=token_row.token, from_club_id=plan.from_club_id, to_club_id=plan.to_club_id, from_uid=str(token_row.from_uid), to_uid=str(to_uid), migrate_seq=migrate_seq, status=ClubMigrateLedger.STATUS_SUCCESS, summary_json={'item_count': len(items_payload), 'keys': keys}, client_meta=client_meta or {}, ) for it in items_payload: ClubMigrateLedgerItem.query.create( ledger_id=ledger.id, plan_id=plan.plan_id, item_type=it['item_type'], role=it['role'], field=it.get('field', ''), amount=_money(it.get('amount') or 0), before_src=str(it.get('before_src', '')), after_src=str(it.get('after_src', '')), before_dst=str(it.get('before_dst', '')), after_dst=str(it.get('after_dst', '')), huiyuan_id=str(it.get('huiyuan_id') or ''), meta_json=it.get('meta_json') or {}, ) cons.success_count = migrate_seq cons.last_ledger_id = ledger.id cons.save(update_fields=['success_count', 'last_ledger_id', 'UpdateTime']) token_row.status = ClubMigrateToken.STATUS_CONSUMED token_row.consumed_at = _now() token_row.save(update_fields=['status', 'consumed_at', 'UpdateTime']) logger.info( 'club_migrate ok plan=%s seq=%s from=%s to=%s ledger=%s items=%s', plan.plan_id, migrate_seq, token_row.from_uid, to_uid, ledger.id, len(items_payload), ) return serialize_ledger(ledger), '' def _transfer_balance_field( *, from_user, to_user, role, field, kind, key, label, to_club_id: str = '', freeze_biz_id: str = '', ) -> dict: src_prof = _lock_profile(from_user, role) amount = _read_field(src_prof, field, kind) before_src = amount after_src = Decimal('0.00') if kind != 'int' else 0 if src_prof is not None and amount: if kind == 'int': type(src_prof).objects.filter(pk=src_prof.pk).update(**{field: 0}) else: type(src_prof).objects.filter(pk=src_prof.pk).update(**{field: Decimal('0.00')}) dst_prof = _lock_profile(to_user, role) created = False if dst_prof is None: dst_prof = _get_or_create_profile(to_user, role, to_club_id=to_club_id) created = True dst_prof = _lock_profile(to_user, role) before_dst = _read_field(dst_prof, field, kind) if dst_prof else (0 if kind == 'int' else Decimal('0.00')) after_dst = before_dst meta_extra = {} if dst_prof is not None and amount: # 积分:目标已满 10 保持 10;相加后也不得超过 10 if field == 'jifen' and kind == 'int': if int(before_dst) >= MEMBER_JIFEN_CAP: after_dst = MEMBER_JIFEN_CAP else: after_dst = min(MEMBER_JIFEN_CAP, int(before_dst) + int(amount)) type(dst_prof).objects.filter(pk=dst_prof.pk).update(jifen=int(after_dst)) meta_extra['jifen_capped'] = True meta_extra['jifen_cap'] = MEMBER_JIFEN_CAP elif (role, field) in _FREEZE_CREDIT_FIELDS: # 可提现入账:走目标店冻结网关(开了冻结则按配置拆分) from jituan.services.fund_freeze import credit_role_balance, SOURCE_CLUB_MIGRATE to_uid = str(getattr(to_user, 'UserUID', '') or '') credit_res = credit_role_balance( club_id=to_club_id, user_id=to_uid, role=role, amount=_money(amount), source_type=SOURCE_CLUB_MIGRATE, biz_id=(freeze_biz_id or f'cm:{to_uid}:{key}')[:64], profile=dst_prof, freeze_reason='跨店资产迁移入账', ignore_source_scope=True, ) dst_prof = _lock_profile(to_user, role) after_dst = _read_field(dst_prof, field, kind) if dst_prof else before_dst meta_extra.update({ 'freeze_available': str(credit_res.get('available') or 0), 'freeze_frozen': str(credit_res.get('frozen') or 0), 'freeze_ledger_id': credit_res.get('ledger_id'), 'freeze_skipped': bool(credit_res.get('skipped_freeze')), 'freeze_skip_reason': credit_res.get('skip_reason'), }) elif kind == 'int': type(dst_prof).objects.filter(pk=dst_prof.pk).update(**{field: F(field) + int(amount)}) after_dst = int(before_dst) + int(amount) else: # 押金 / 已冻结池等:原样累加,不再二次冻结 type(dst_prof).objects.filter(pk=dst_prof.pk).update(**{field: F(field) + _money(amount)}) after_dst = _money(before_dst) + _money(amount) return { 'item_type': ClubMigrateLedgerItem.ITEM_BALANCE, 'role': role, 'field': field, 'amount': amount, 'before_src': before_src, 'after_src': after_src if amount else before_src, 'before_dst': before_dst, 'after_dst': after_dst, 'meta_json': {'key': key, 'label': label, 'dst_created': created, **meta_extra}, } def _fmt_ledger_time(v: str) -> str: s = str(v or '').strip() if not s or s in ('skipped', 'expired') or 'T' not in s: return s try: dt = datetime.fromisoformat(s.replace('Z', '+00:00')) if timezone.is_naive(dt): dt = timezone.make_aware(dt, timezone.get_current_timezone()) return timezone.localtime(dt).strftime('%Y-%m-%d %H:%M') except Exception: return s def serialize_ledger(ledger: ClubMigrateLedger) -> dict: items = list(ClubMigrateLedgerItem.query.filter(ledger_id=ledger.id).order_by('id')) return { 'ledger_id': ledger.id, 'plan_id': ledger.plan_id, 'from_club_id': ledger.from_club_id, 'to_club_id': ledger.to_club_id, 'from_uid': ledger.from_uid, 'to_uid': ledger.to_uid, 'migrate_seq': ledger.migrate_seq, 'status': ledger.status, 'created_at': ledger.CreateTime.isoformat() if ledger.CreateTime else '', 'items': [ { 'item_type': it.item_type, 'role': it.role, 'field': it.field, 'amount': str(it.amount), 'before_src': it.before_src, 'after_src': it.after_src, 'before_dst': it.before_dst, 'after_dst': _fmt_ledger_time(it.after_dst) if it.field == 'huiyuan' else it.after_dst, 'huiyuan_id': it.huiyuan_id, 'meta': it.meta_json or {}, 'label': (it.meta_json or {}).get('name') or (it.meta_json or {}).get('label') or f'{it.role}.{it.field}', } for it in items ], } def list_user_records(uid: str, limit: int = 50) -> List[dict]: qs = ( ClubMigrateLedger.query.filter(Q(from_uid=str(uid)) | Q(to_uid=str(uid))) .order_by('-id')[: max(1, min(int(limit or 50), 100))] ) return [serialize_ledger(x) for x in qs] def serialize_plan(plan: ClubMigratePlan) -> dict: return { 'plan_id': plan.plan_id, 'name': plan.name or '', 'from_club_id': plan.from_club_id, 'to_club_id': plan.to_club_id, 'enabled': bool(plan.enabled), 'start_at': plan.start_at.isoformat() if plan.start_at else '', 'end_at': plan.end_at.isoformat() if plan.end_at else '', 'asset_whitelist': normalize_whitelist(plan.asset_whitelist), 'condition_json': plan.condition_json or {}, 'force_intercept': bool(plan.force_intercept), 'force_roles': plan.force_roles or ['dashou'], 'default_once_per_user': bool(plan.default_once_per_user), 'remark': plan.remark or '', 'updated_by': plan.updated_by or '', 'created_at': plan.CreateTime.isoformat() if plan.CreateTime else '', 'updated_at': plan.UpdateTime.isoformat() if plan.UpdateTime else '', } def serialize_token(token_row: ClubMigrateToken) -> dict: qr = (token_row.qrcode_url or '').strip() if qr and not qr.startswith('http'): from django.conf import settings cos_domain = getattr(settings, 'COS_DOMAIN', '').rstrip('/') if cos_domain: qr = f'{cos_domain}/{qr.lstrip("/")}' return { 'token': token_row.token, 'scene': scene_for_token(token_row.token), 'plan_id': token_row.plan_id, 'from_club_id': token_row.from_club_id, 'to_club_id': token_row.to_club_id, 'status': token_row.status, 'qrcode_url': qr, 'expire_at': token_row.expire_at.isoformat() if token_row.expire_at else '', 'asset_snapshot': token_row.asset_snapshot or {}, } def lookup_migrate_user_brief(uid: str) -> dict: """后台二次名额:校验用户并返回简要资料。""" uid = str(uid or '').strip() user = _load_user(uid) if not user: return {'exists': False, 'from_uid': uid, 'nicheng': '', 'phone': ''} nicheng = '' try: ds = getattr(user, 'DashouProfile', None) if ds and getattr(ds, 'nicheng', None): nicheng = str(ds.nicheng) except Exception: pass if not nicheng: nicheng = str(getattr(user, 'UserName', '') or getattr(user, 'Phone', '') or '') return { 'exists': True, 'from_uid': uid, 'nicheng': nicheng, 'phone': str(getattr(user, 'Phone', '') or ''), 'club_id': str(getattr(user, 'ClubID', '') or ''), } def serialize_user_quota(row: ClubMigrateUserQuota, plan: Optional[ClubMigratePlan] = None, user_info: Optional[dict] = None) -> dict: plan = plan or ClubMigratePlan.query.filter(plan_id=row.plan_id).first() info = user_info if user_info is not None else lookup_migrate_user_brief(row.from_uid) used = success_count(row.plan_id, row.from_uid) allowed = max_allowed_success(plan, row.from_uid) if plan else (1 + int(row.max_extra_times or 0)) return { 'plan_id': row.plan_id, 'from_uid': row.from_uid, 'max_extra_times': int(row.max_extra_times or 0), 'reason': row.reason or '', 'updated_by': row.updated_by or '', 'created_at': row.CreateTime.isoformat() if row.CreateTime else '', 'updated_at': row.UpdateTime.isoformat() if row.UpdateTime else '', 'nicheng': info.get('nicheng') or '', 'phone': info.get('phone') or '', 'user_club_id': info.get('club_id') or '', 'success_count': used, 'max_allowed': allowed, 'remaining': max(0, int(allowed) - int(used)), 'can_migrate_again': used < allowed, }