fix: 会员按公共ID原样过户,禁止静默skip,残留资产可补迁
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -136,7 +136,11 @@ def success_count(plan_id: str, from_uid: str) -> int:
|
||||
|
||||
|
||||
def can_migrate_again(plan: ClubMigratePlan, from_uid: str) -> bool:
|
||||
return success_count(plan.plan_id, from_uid) < max_allowed_success(plan, from_uid)
|
||||
"""有剩余次数,或上次未迁干净(源店仍有可迁资产)时可再迁。"""
|
||||
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):
|
||||
@@ -353,75 +357,15 @@ def _preview_huiyuan(uid: str, club_id: str, role: str) -> List[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def _club_huiyuan_enabled(club_id: str, huiyuan_id: str) -> bool:
|
||||
from jituan.models import ClubHuiyuanPrice
|
||||
from jituan.services.huiyuan_bundle import expand_huiyuan_id_variants
|
||||
|
||||
for vid in expand_huiyuan_id_variants(huiyuan_id):
|
||||
if ClubHuiyuanPrice.query.filter(
|
||||
club_id=str(club_id), huiyuan_id=str(vid), is_enabled=True,
|
||||
).exists():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _bundle_child_ids(club_id: str, bundle_huiyuan_id: str) -> List[str]:
|
||||
from jituan.models import ClubHuiyuanBundleInclude
|
||||
from jituan.services.huiyuan_bundle import expand_huiyuan_id_variants
|
||||
|
||||
kids = []
|
||||
seen = set()
|
||||
for bid in expand_huiyuan_id_variants(bundle_huiyuan_id):
|
||||
qs = ClubHuiyuanBundleInclude.query.filter(
|
||||
club_id=str(club_id), bundle_huiyuan_id=str(bid),
|
||||
)
|
||||
for x in qs:
|
||||
cid = str(x.included_huiyuan_id or '')
|
||||
if cid and cid not in seen:
|
||||
seen.add(cid)
|
||||
kids.append(cid)
|
||||
if kids:
|
||||
return kids
|
||||
# 源店未配包含关系时,回退任意俱乐部同总会员配置
|
||||
for bid in expand_huiyuan_id_variants(bundle_huiyuan_id):
|
||||
qs2 = ClubHuiyuanBundleInclude.query.filter(bundle_huiyuan_id=str(bid))
|
||||
for x in qs2:
|
||||
cid = str(x.included_huiyuan_id or '')
|
||||
if cid and cid not in seen:
|
||||
seen.add(cid)
|
||||
kids.append(cid)
|
||||
return kids
|
||||
|
||||
|
||||
def _resolve_migrate_target_huiyuan_ids(
|
||||
*, src_huiyuan_id: str, from_club: str, to_club: str,
|
||||
*, src_huiyuan_id: str, from_club: str = '', to_club: str = '',
|
||||
) -> List[str]:
|
||||
"""
|
||||
目标店开通了同 ID → 迁该 ID;
|
||||
总会员目标店无对应 ID → 按源店子会员映射到目标店已开通的子会员;
|
||||
仍无映射 → 仍迁同 ID(打手端 clumber 按开通记录展示,不依赖目标店上架)。
|
||||
会员 ID 集团公共(含总会员):原样迁同一 ID。
|
||||
不要求目标店 ClubHuiyuanPrice 上架,不拆成子会员。
|
||||
"""
|
||||
from products.models import Huiyuan
|
||||
from jituan.services.huiyuan_bundle import is_bundle_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]
|
||||
|
||||
# 总会员:尽量映射到目标店已开通子会员
|
||||
if is_bundle_huiyuan(src_id):
|
||||
children = _bundle_child_ids(from_club, src_id)
|
||||
out = []
|
||||
for cid in children:
|
||||
if _club_huiyuan_enabled(to_club, cid):
|
||||
out.append(cid)
|
||||
if out:
|
||||
return out
|
||||
|
||||
# 兜底:原样迁入同 ID,保证目标账号有开通记录可展示
|
||||
return [src_id]
|
||||
return [src_id] if src_id else []
|
||||
|
||||
|
||||
def _revoke_source_member_czjilu(from_uid: str, huiyuan_id: str):
|
||||
@@ -467,6 +411,21 @@ def _expire_source_huiyuan(row, now):
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -476,10 +435,8 @@ def _add_huiyuan_duration(*, to_uid: str, to_club: str, huiyuan_id: str, remain,
|
||||
if remain is None:
|
||||
return None, {}
|
||||
|
||||
dst = Huiyuangoumai.objects.select_for_update().filter(
|
||||
yonghu_id=str(to_uid),
|
||||
huiyuan_id=str(huiyuan_id),
|
||||
).first()
|
||||
hid = str(huiyuan_id).strip()
|
||||
dst = _find_user_huiyuangoumai(to_uid, hid, for_update=True)
|
||||
before_dst = {}
|
||||
if dst:
|
||||
before_dst = {
|
||||
@@ -499,20 +456,45 @@ def _add_huiyuan_duration(*, to_uid: str, to_club: str, huiyuan_id: str, remain,
|
||||
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),
|
||||
)
|
||||
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,含总会员),源店置已到期。
|
||||
写目标失败则整笔迁移失败回滚,禁止静默 skip。
|
||||
"""
|
||||
from products.models import Huiyuan
|
||||
|
||||
now = _now_aware()
|
||||
@@ -533,95 +515,56 @@ def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dic
|
||||
'record_club_id': (r.club_id or ''),
|
||||
}
|
||||
remain = (src_exp - now) if src_exp and src_exp > now else None
|
||||
target_ids = _resolve_migrate_target_huiyuan_ids(
|
||||
src_huiyuan_id=r.huiyuan_id, from_club=from_club, to_club=to_club,
|
||||
)
|
||||
if remain is None or not target_ids:
|
||||
out.append({
|
||||
'item_type': ClubMigrateLedgerItem.ITEM_HUIYUAN,
|
||||
'role': role,
|
||||
'field': 'huiyuan',
|
||||
'amount': Decimal('0'),
|
||||
'before_src': before.get('daoqi_time', ''),
|
||||
'after_src': 'kept',
|
||||
'before_dst': '',
|
||||
'after_dst': 'skipped',
|
||||
'huiyuan_id': r.huiyuan_id,
|
||||
'meta_json': {
|
||||
'before_src': before,
|
||||
'skip_reason': 'no_remain' if remain is None else 'no_target',
|
||||
'name': name,
|
||||
},
|
||||
})
|
||||
if remain is None:
|
||||
# 列表时未过期、过户瞬间过期:跳过该条即可
|
||||
continue
|
||||
|
||||
# 先写入目标,再失效源 + 撤销 Czjilu(避免目标写失败却已抹源)
|
||||
transferred = False
|
||||
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
|
||||
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)
|
||||
tid = (_resolve_migrate_target_huiyuan_ids(src_huiyuan_id=r.huiyuan_id) or [None])[0]
|
||||
if not tid:
|
||||
raise ValueError(f'会员 ID 无效,无法迁移: {r.huiyuan_id}')
|
||||
|
||||
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),
|
||||
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,
|
||||
)
|
||||
if exp is None:
|
||||
continue
|
||||
transferred = 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': r.huiyuan_id,
|
||||
'name': child_name,
|
||||
'mapped_from_bundle': tid != r.huiyuan_id,
|
||||
'formal_purchase_floor': formal_floor,
|
||||
'is_trial': bool(r.is_trial),
|
||||
},
|
||||
})
|
||||
formal_floor = max(int(src_paid or 0), 1)
|
||||
|
||||
if transferred:
|
||||
_expire_source_huiyuan(r, now)
|
||||
else:
|
||||
out.append({
|
||||
'item_type': ClubMigrateLedgerItem.ITEM_HUIYUAN,
|
||||
'role': role,
|
||||
'field': 'huiyuan',
|
||||
'amount': Decimal('0'),
|
||||
'before_src': before.get('daoqi_time', ''),
|
||||
'after_src': 'kept',
|
||||
'before_dst': '',
|
||||
'after_dst': 'skipped',
|
||||
'huiyuan_id': r.huiyuan_id,
|
||||
'meta_json': {
|
||||
'before_src': before,
|
||||
'skip_reason': 'add_duration_failed',
|
||||
'name': name,
|
||||
},
|
||||
})
|
||||
# 先写目标,再失效源(目标失败则事务回滚,源不变)
|
||||
exp, before_dst = _add_huiyuan_duration(
|
||||
to_uid=to_uid,
|
||||
to_club=to_club,
|
||||
huiyuan_id=tid,
|
||||
remain=remain,
|
||||
jieshao=name,
|
||||
is_trial=bool(r.is_trial),
|
||||
has_used_trial=bool(r.has_used_trial),
|
||||
)
|
||||
if exp is None:
|
||||
raise ValueError(f'会员过户失败(加时长): {name}({tid})')
|
||||
|
||||
_expire_source_huiyuan(r, now)
|
||||
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': name,
|
||||
'mapped_from_bundle': False,
|
||||
'formal_purchase_floor': formal_floor,
|
||||
'is_trial': bool(r.is_trial),
|
||||
},
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@@ -845,8 +788,7 @@ def confirm_migrate(
|
||||
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:
|
||||
if not can_migrate_again(plan, token_row.from_uid):
|
||||
return None, '本计划已迁移完成'
|
||||
|
||||
keys = normalize_whitelist(plan.asset_whitelist)
|
||||
@@ -859,32 +801,36 @@ def confirm_migrate(
|
||||
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,
|
||||
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],
|
||||
)
|
||||
)
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user