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) 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.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 from products.models import Huiyuangoumai
now = _now_aware() now = _now_aware()
@@ -305,16 +421,13 @@ def _list_active_huiyuan_rows(uid: str, from_club: str, *, for_update: bool = Fa
) )
if for_update: if for_update:
qs = qs.select_for_update() qs = qs.select_for_update()
rows = list(qs)
out = [] out = []
for r in rows: for r in qs:
exp = _aware(r.daoqi_time) exp = _aware(r.daoqi_time)
if not exp or exp <= now: if not exp or exp <= now:
continue continue
rec_cid = (getattr(r, 'club_id', None) or '').strip() if not _club_row_belongs_to_source(getattr(r, 'club_id', None) or '', from_club):
if rec_cid and rec_cid not in (from_club, CLUB_ID_DEFAULT, ''): continue
if from_club and rec_cid != from_club:
continue
out.append(r) out.append(r)
return out 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]: def _preview_huiyuan(uid: str, club_id: str, role: str) -> List[dict]:
from products.models import Huiyuan from products.models import Huiyuan
rows = _list_active_huiyuan_rows(uid, club_id) entries = _collect_migratable_huiyuan(uid, club_id)
if not rows: if not entries:
return [] return []
ids = [r.huiyuan_id for r in rows] ids = [e['huiyuan_id'] for e in entries]
name_map = { name_map = {
h.huiyuan_id: (h.jieshao or '').strip() or '会员' h.huiyuan_id: (h.jieshao or '').strip() or '会员'
for h in Huiyuan.query.filter(huiyuan_id__in=ids) for h in Huiyuan.query.filter(huiyuan_id__in=ids)
} }
out = [] out = []
for r in rows: for e in entries:
name = (r.jieshao or '').strip() or name_map.get(r.huiyuan_id) or '会员' name = e.get('jieshao') or name_map.get(e['huiyuan_id']) or '会员'
exp = _aware(r.daoqi_time) exp = _aware(e.get('daoqi_time'))
if exp is None: if exp is None:
exp_text = '' exp_text = ''
elif timezone.is_aware(exp): elif timezone.is_aware(exp):
exp_text = timezone.localtime(exp).strftime('%Y-%m-%d %H:%M') exp_text = timezone.localtime(exp).strftime('%Y-%m-%d %H:%M')
else: else:
exp_text = exp.strftime('%Y-%m-%d %H:%M') exp_text = exp.strftime('%Y-%m-%d %H:%M')
src_tag = '开通' if e.get('source') == 'goumai' else '充值权益'
out.append({ out.append({
'key': f'{role}.huiyuan:{r.huiyuan_id}', 'key': f'{role}.huiyuan:{e["huiyuan_id"]}',
'role': role, 'role': role,
'field': 'huiyuan', 'field': 'huiyuan',
'label': name, 'label': name,
'kind': 'huiyuan', 'kind': 'huiyuan',
'amount': exp_text, 'amount': exp_text,
'display': f'{name} · 到期 {exp_text}' if exp_text else name, '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 '', 'daoqi_time': exp.isoformat() if exp else '',
'jieshao': name, 'jieshao': name,
'has_profile': True, '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 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): def _find_user_huiyuangoumai(uid: str, huiyuan_id: str, *, for_update: bool = False):
"""按会员 ID 变体查找购买记录001 / 1 等同)。""" """按会员 ID 变体查找购买记录001 / 1 等同)。"""
from jituan.services.huiyuan_bundle import expand_huiyuan_id_variants 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]: def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dict]:
""" """
未到期会员:目标店加同等剩余时长(同会员 ID含总会员源店置已到期。 未到期会员:目标店加同等剩余时长(同会员 ID含总会员源店置已到期并撤销充值权益
写目标失败则整笔迁移失败回滚,禁止静默 skip 数据来源与打手端 clumber 一致(开通表 + 充值单)
""" """
from products.models import Huiyuan from products.models import Huiyuan
now = _now_aware() 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 = [] out = []
for r in rows: for e in entries:
src_exp = _aware(r.daoqi_time) src_exp = _aware(e.get('daoqi_time'))
name = (r.jieshao or '').strip() name = (e.get('jieshao') or '').strip()
if not name: 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 '会员' name = (hy.jieshao if hy else '') or '会员'
before = { before = {
'huiyuan_id': r.huiyuan_id, 'huiyuan_id': e['huiyuan_id'],
'daoqi_time': src_exp.isoformat() if src_exp else '', 'daoqi_time': src_exp.isoformat() if src_exp else '',
'zhuangtai': r.huiyuan_zhuangtai, 'zhuangtai': 1,
'is_trial': bool(r.is_trial), 'is_trial': bool(e.get('is_trial')),
'jieshao': name, '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 remain = (src_exp - now) if src_exp and src_exp > now else None
if remain is None: if remain is None:
# 列表时未过期、过户瞬间过期:跳过该条即可
continue 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: if not tid:
raise ValueError(f'会员 ID 无效,无法迁移: {r.huiyuan_id}') raise ValueError(f'会员 ID 无效,无法迁移: {e["huiyuan_id"]}')
formal_floor = 0 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 from jituan.services.member_recharge import count_formal_paid_member_orders
src_paid = 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) formal_floor = max(int(src_paid or 0), 1)
# 先写目标,再失效源(目标失败则事务回滚,源不变)
exp, before_dst = _add_huiyuan_duration( exp, before_dst = _add_huiyuan_duration(
to_uid=to_uid, to_uid=to_uid,
to_club=to_club, to_club=to_club,
huiyuan_id=tid, huiyuan_id=tid,
remain=remain, remain=remain,
jieshao=name, jieshao=name,
is_trial=bool(r.is_trial), is_trial=bool(e.get('is_trial')),
has_used_trial=bool(r.has_used_trial), has_used_trial=bool(e.get('has_used_trial')),
) )
if exp is None: if exp is None:
raise ValueError(f'会员过户失败(加时长): {name}({tid})') 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({ out.append({
'item_type': ClubMigrateLedgerItem.ITEM_HUIYUAN, 'item_type': ClubMigrateLedgerItem.ITEM_HUIYUAN,
'role': role, 'role': role,
@@ -558,11 +696,12 @@ def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dic
'meta_json': { 'meta_json': {
'before_src': before, 'before_src': before,
'before_dst': before_dst, 'before_dst': before_dst,
'src_huiyuan_id': r.huiyuan_id, 'src_huiyuan_id': e['huiyuan_id'],
'name': name, 'name': name,
'mapped_from_bundle': False, 'mapped_from_bundle': False,
'formal_purchase_floor': formal_floor, '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 return out