fix: 会员按公共ID原样过户,禁止静默skip,残留资产可补迁

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-29 04:25:23 +08:00
parent 5f3a5f9e08
commit 2ab97b0579

View File

@@ -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,