Files
Django/jituan/services/club_migrate.py

1000 lines
35 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:
return success_count(plan.plan_id, from_uid) < max_allowed_success(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 _preview_huiyuan(uid: str, club_id: str, role: str) -> List[dict]:
from products.models import Huiyuan, Huiyuangoumai
now = _now()
rows = list(
Huiyuangoumai.query.filter(
yonghu_id=str(uid),
club_id=club_id,
huiyuan_zhuangtai=1,
daoqi_time__gt=now,
)
)
if not rows:
return []
ids = [r.huiyuan_id for r in rows]
name_map = {
h.huiyuan_id: (h.jieshao or '').strip() or '会员'
for h in Huiyuan.query.filter(huiyuan_id__in=ids)
}
out = []
for r in rows:
name = (r.jieshao or '').strip() or name_map.get(r.huiyuan_id) or '会员'
exp = _aware(r.daoqi_time)
exp_text = timezone.localtime(exp).strftime('%Y-%m-%d %H:%M') if exp else ''
out.append({
'key': f'{role}.huiyuan:{r.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': r.huiyuan_id,
'daoqi_time': exp.isoformat() if exp else '',
'jieshao': name,
'has_profile': True,
})
return out
def _club_huiyuan_enabled(club_id: str, huiyuan_id: str) -> bool:
from jituan.models import ClubHuiyuanPrice
row = ClubHuiyuanPrice.query.filter(
club_id=str(club_id), huiyuan_id=str(huiyuan_id), is_enabled=True,
).first()
return bool(row)
def _bundle_child_ids(club_id: str, bundle_huiyuan_id: str) -> List[str]:
from jituan.models import ClubHuiyuanBundleInclude
qs = ClubHuiyuanBundleInclude.query.filter(
club_id=str(club_id), bundle_huiyuan_id=str(bundle_huiyuan_id),
)
kids = [str(x.included_huiyuan_id) for x in qs if x.included_huiyuan_id]
if kids:
return kids
# 源店未配包含关系时,回退任意俱乐部同总会员配置
qs2 = ClubHuiyuanBundleInclude.query.filter(bundle_huiyuan_id=str(bundle_huiyuan_id))
seen = set()
out = []
for x in qs2:
cid = str(x.included_huiyuan_id or '')
if cid and cid not in seen:
seen.add(cid)
out.append(cid)
return out
def _resolve_migrate_target_huiyuan_ids(
*, src_huiyuan_id: str, from_club: str, to_club: str,
) -> List[str]:
"""
目标店开通了同 ID → 迁该 ID
总会员目标店无对应 ID → 按源店子会员映射到目标店已开通的子会员。
"""
from products.models import Huiyuan
src_id = str(src_huiyuan_id or '').strip()
if not src_id:
return []
if _club_huiyuan_enabled(to_club, src_id):
return [src_id]
hy = Huiyuan.query.filter(huiyuan_id=src_id).first()
if not hy or not bool(getattr(hy, 'is_bundle', False)):
return []
children = _bundle_child_ids(from_club, src_id)
out = []
for cid in children:
if _club_huiyuan_enabled(to_club, cid):
out.append(cid)
return out
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()
remain = remain if remain and remain.total_seconds() > 0 else None
if remain is None:
return None, {}
dst = Huiyuangoumai.objects.select_for_update().filter(
yonghu_id=str(to_uid),
huiyuan_id=str(huiyuan_id),
).first()
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
Huiyuangoumai.query.create(
huiyuan_id=str(huiyuan_id),
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),
)
return exp, before_dst
def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dict]:
from products.models import Huiyuan, Huiyuangoumai
now = _now()
rows = list(
Huiyuangoumai.objects.select_for_update().filter(
yonghu_id=str(from_uid),
club_id=from_club,
huiyuan_zhuangtai=1,
daoqi_time__gt=now,
)
)
out = []
for r in rows:
src_exp = _aware(r.daoqi_time)
name = (r.jieshao or '').strip()
if not name:
hy = Huiyuan.query.filter(huiyuan_id=r.huiyuan_id).first()
name = (hy.jieshao if hy else '') or '会员'
before = {
'huiyuan_id': r.huiyuan_id,
'daoqi_time': src_exp.isoformat() if src_exp else '',
'zhuangtai': r.huiyuan_zhuangtai,
'is_trial': bool(r.is_trial),
'jieshao': name,
}
remain = (src_exp - now) if src_exp and src_exp > now else None
# 源失效(保留原到期进流水 before
r.huiyuan_zhuangtai = 0
r.daoqi_time = now
r.save(update_fields=['huiyuan_zhuangtai', 'daoqi_time', 'UpdateTime'])
target_ids = _resolve_migrate_target_huiyuan_ids(
src_huiyuan_id=r.huiyuan_id, from_club=from_club, to_club=to_club,
)
if not target_ids or remain is None:
out.append({
'item_type': ClubMigrateLedgerItem.ITEM_HUIYUAN,
'role': role,
'field': 'huiyuan',
'amount': Decimal('0'),
'before_src': before.get('daoqi_time', ''),
'after_src': 'expired',
'before_dst': '',
'after_dst': 'skipped',
'huiyuan_id': r.huiyuan_id,
'meta_json': {
'before_src': before,
'skip_reason': 'target_not_enabled' if not target_ids else 'no_remain',
'name': name,
},
})
continue
for tid in target_ids:
child_name = name
if tid != r.huiyuan_id:
chy = Huiyuan.query.filter(huiyuan_id=tid).first()
child_name = (chy.jieshao if chy else '') or tid
# 正式会员迁入:继承源店正式购买次数,且至少 1再充不可能算首次分红
formal_floor = 0
if not bool(r.is_trial):
from jituan.services.member_recharge import count_formal_paid_member_orders
src_paid = count_formal_paid_member_orders(
from_uid, r.huiyuan_id, from_club,
)
formal_floor = max(int(src_paid or 0), 1)
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(r.is_trial),
has_used_trial=bool(r.has_used_trial),
)
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': r.huiyuan_id,
'name': child_name,
'mapped_from_bundle': tid != r.huiyuan_id,
'formal_purchase_floor': formal_floor,
'is_trial': bool(r.is_trial),
},
})
return out
def _aware(dt):
if dt is None:
return None
if timezone.is_naive(dt):
return timezone.make_aware(dt, timezone.get_current_timezone())
return dt
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(has_assets and 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()
allowed = max_allowed_success(plan, token_row.from_uid)
if int(cons.success_count or 0) >= allowed:
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}'
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],
)
)
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 {},
}