Files
Django/jituan/services/club_migrate.py
2026-07-29 04:37:08 +08:00

1312 lines
47 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.
"""跨俱乐部资产迁移核心服务(独立模块,未开计划时零影响)。
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 _add_huiyuan_duration(*, to_uid: str, to_club: str, huiyuan_id: str, remain, jieshao: str, is_trial: bool, has_used_trial: bool):
"""给目标用户对应会员加时长(已有则累加剩余,否则从现在起算)。"""
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,
}
dst_exp = _aware(dst.daoqi_time)
base = dst_exp if (dst_exp and dst_exp > now and int(dst.huiyuan_zhuangtai or 0) == 1) else now
exp = base + remain
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
exp = now + remain
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,
}
dst_exp = _aware(dst2.daoqi_time)
base = dst_exp if (dst_exp and dst_exp > now and int(dst2.huiyuan_zhuangtai or 0) == 1) else now
exp = base + 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)
# 强制:仍要有可迁资产(空资产别拦死首页)
# 入口:只要还有剩余次数就展示(含二次名额),不必依赖第一次强制拦截
force = bool(plan.force_intercept and role_hit and not done and has_assets)
show_entry = bool(not done)
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,
'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,
}