fix: 二次迁移可迁账上仍有效的充值单合成会员

EOF

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

View File

@@ -288,13 +288,129 @@ def user_has_migratable_assets(plan: ClubMigratePlan, from_uid: str) -> bool:
return any(asset_item_has_value(it) for it in items)
def _list_active_huiyuan_rows(uid: str, from_club: str, *, for_update: bool = False):
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]:
"""
源店可迁会员:按用户取有效会员,不盲信 Huiyuangoumai.club_id
历史数据常把 club_id 写成默认 xq/空,严格按 from_club 过滤会漏迁。
仅排除「明确属于其他店」的记录。
可迁会员 = 用户端能看到的有效会员(开通表 + 未撤销充值单合成)
第一次迁移若只弄坏了开通表、充值单还在,第二次仍能迁。
"""
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()
@@ -305,16 +421,13 @@ def _list_active_huiyuan_rows(uid: str, from_club: str, *, for_update: bool = Fa
)
if for_update:
qs = qs.select_for_update()
rows = list(qs)
out = []
for r in rows:
for r in qs:
exp = _aware(r.daoqi_time)
if not exp or exp <= now:
continue
rec_cid = (getattr(r, 'club_id', None) or '').strip()
if rec_cid and rec_cid not in (from_club, CLUB_ID_DEFAULT, ''):
if from_club and rec_cid != from_club:
continue
if not _club_row_belongs_to_source(getattr(r, 'club_id', None) or '', from_club):
continue
out.append(r)
return out
@@ -322,37 +435,40 @@ def _list_active_huiyuan_rows(uid: str, from_club: str, *, for_update: bool = Fa
def _preview_huiyuan(uid: str, club_id: str, role: str) -> List[dict]:
from products.models import Huiyuan
rows = _list_active_huiyuan_rows(uid, club_id)
if not rows:
entries = _collect_migratable_huiyuan(uid, club_id)
if not entries:
return []
ids = [r.huiyuan_id for r in rows]
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 r in rows:
name = (r.jieshao or '').strip() or name_map.get(r.huiyuan_id) or '会员'
exp = _aware(r.daoqi_time)
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:{r.huiyuan_id}',
'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': r.huiyuan_id,
'huiyuan_id': e['huiyuan_id'],
'daoqi_time': exp.isoformat() if exp else '',
'jieshao': name,
'has_profile': True,
'record_club_id': (r.club_id or ''),
'record_club_id': e.get('record_club_id') or '',
'source': e.get('source') or '',
'source_tag': src_tag,
})
return out
@@ -411,6 +527,25 @@ def _expire_source_huiyuan(row, now):
)
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
@@ -492,59 +627,62 @@ def _add_huiyuan_duration(*, to_uid: str, to_club: str, huiyuan_id: str, remain,
def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dict]:
"""
未到期会员:目标店加同等剩余时长(同会员 ID含总会员源店置已到期。
写目标失败则整笔迁移失败回滚,禁止静默 skip
未到期会员:目标店加同等剩余时长(同会员 ID含总会员源店置已到期并撤销充值权益
数据来源与打手端 clumber 一致(开通表 + 充值单)
"""
from products.models import Huiyuan
now = _now_aware()
rows = _list_active_huiyuan_rows(from_uid, from_club, for_update=True)
entries = _collect_migratable_huiyuan(from_uid, from_club, for_update=True)
out = []
for r in rows:
src_exp = _aware(r.daoqi_time)
name = (r.jieshao or '').strip()
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=r.huiyuan_id).first()
hy = Huiyuan.query.filter(huiyuan_id=e['huiyuan_id']).first()
name = (hy.jieshao if hy else '') or '会员'
before = {
'huiyuan_id': r.huiyuan_id,
'huiyuan_id': e['huiyuan_id'],
'daoqi_time': src_exp.isoformat() if src_exp else '',
'zhuangtai': r.huiyuan_zhuangtai,
'is_trial': bool(r.is_trial),
'zhuangtai': 1,
'is_trial': bool(e.get('is_trial')),
'jieshao': name,
'record_club_id': (r.club_id or ''),
'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
tid = (_resolve_migrate_target_huiyuan_ids(src_huiyuan_id=r.huiyuan_id) or [None])[0]
tid = (_resolve_migrate_target_huiyuan_ids(src_huiyuan_id=e['huiyuan_id']) or [None])[0]
if not tid:
raise ValueError(f'会员 ID 无效,无法迁移: {r.huiyuan_id}')
raise ValueError(f'会员 ID 无效,无法迁移: {e["huiyuan_id"]}')
formal_floor = 0
if not bool(r.is_trial):
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, r.huiyuan_id, from_club,
from_uid, e['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=name,
is_trial=bool(r.is_trial),
has_used_trial=bool(r.has_used_trial),
is_trial=bool(e.get('is_trial')),
has_used_trial=bool(e.get('has_used_trial')),
)
if exp is None:
raise ValueError(f'会员过户失败(加时长): {name}({tid})')
_expire_source_huiyuan(r, now)
_expire_source_membership(
from_uid=from_uid,
huiyuan_id=e['huiyuan_id'],
goumai_row=e.get('goumai_row'),
)
out.append({
'item_type': ClubMigrateLedgerItem.ITEM_HUIYUAN,
'role': role,
@@ -558,11 +696,12 @@ def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dic
'meta_json': {
'before_src': before,
'before_dst': before_dst,
'src_huiyuan_id': r.huiyuan_id,
'src_huiyuan_id': e['huiyuan_id'],
'name': name,
'mapped_from_bundle': False,
'formal_purchase_floor': formal_floor,
'is_trial': bool(r.is_trial),
'is_trial': bool(e.get('is_trial')),
'source': e.get('source') or '',
},
})
return out