""" 全面审计会员 sync Bug 影响范围(与 rollback 的 44 条不是同一口径)。 rollback 只收「当前仍错误有效」的记录。 本命令额外统计: - 全库会员/充值单规模 - 当前仍有效 vs 充值单原定期限已过(= rollback 第二类) - 疑似被 repair 写回过(含已再次过期、当前看不出的历史受害) 用法: python manage.py audit_member_bug_impact python manage.py audit_member_bug_impact --since 2026-06-15 """ from datetime import timedelta from django.core.management.base import BaseCommand from django.utils import timezone from jituan.services.huiyuan_bundle import ( _normalize_datetime_for_compare, huiyuan_ids_match, ) from jituan.services.member_recharge import ( MEMBER_LEIXING, MEMBER_PAID_STATUS, _czjilu_entitlement_end, _czjilu_entitlement_revoked, _parse_since_option, club_huiyuan_sellable, _order_is_trial_like, ) from products.models import Czjilu, Huiyuangoumai from users.models import Xiugaijilu def _order_purchase_days(order, club_id=None): cid = getattr(order, 'club_id', None) or club_id or 'xq' is_trial = _order_is_trial_like(order, cid) days = int(getattr(order, 'purchase_days', None) or 0) if days <= 0: _, _, days = club_huiyuan_sellable(cid, order.huiyuan_id, is_trial=is_trial) return max(int(days or 0), 1) def _paid_orders_for(yonghu_id, huiyuan_id): return [ o for o in Czjilu.query.filter( yonghuid=yonghu_id, leixing=MEMBER_LEIXING, zhuangtai=MEMBER_PAID_STATUS, ) if huiyuan_ids_match(o.huiyuan_id, huiyuan_id) ] def _suspected_repair_restore(rec, since=None): """ repair 特征:UpdateTime 时点将 daoqi 设为 UpdateTime+days, 且存在匹配充值单原定期限在 UpdateTime 之前已结束。 """ updated = _normalize_datetime_for_compare(rec.UpdateTime) since_dt = _parse_since_option(since) if since_dt and updated < since_dt: return None daoqi = _normalize_datetime_for_compare(rec.daoqi_time) gap = daoqi - updated gap_days = gap.days if gap_days < 1 or gap_days > 400: return None orders = _paid_orders_for(rec.yonghu_id, rec.huiyuan_id) if not orders: return None for order in orders: if _czjilu_entitlement_revoked(order): continue days = _order_purchase_days(order, rec.club_id) if abs(days - gap_days) > 2: continue end = _czjilu_entitlement_end(order, rec.club_id) if end < updated: return order.dingdan_id return None class Command(BaseCommand): help = '审计会员 sync Bug 影响规模(全库口径 vs rollback 口径)' def add_arguments(self, parser): parser.add_argument( '--since', default='2026-06-01', help='疑似 repair 的 UpdateTime 起始日(默认 2026-06-01)', ) parser.add_argument( '--sample', type=int, default=30, help='每类样例输出条数', ) def handle(self, *args, **options): since = options['since'] sample_n = int(options['sample'] or 30) now = _normalize_datetime_for_compare(timezone.now()) total_hy = Huiyuangoumai.query.count() active_hy = Huiyuangoumai.query.filter( huiyuan_zhuangtai=1, daoqi_time__gt=timezone.now(), ).count() expired_czjilu_rows = 0 expired_czjilu_users = set() for o in Czjilu.query.filter(leixing=MEMBER_LEIXING, zhuangtai=MEMBER_PAID_STATUS): end = _czjilu_entitlement_end(o) if end <= now: expired_czjilu_rows += 1 expired_czjilu_users.add(o.yonghuid) rollback_active_orphan = 0 rollback_admin_still_active = 0 suspected_all = [] suspected_still_active = [] suspected_already_expired = [] seen_admin = set() for log in Xiugaijilu.query.filter(huiyuan_id__isnull=False).exclude(huiyuan_id=''): note = log.qitashuoming or '' if '移除' not in note and '减会员' not in note: continue if not log.yonghuid or not log.huiyuan_id: continue pair = (log.yonghuid, str(log.huiyuan_id).strip()) if pair in seen_admin: continue seen_admin.add(pair) for rec in Huiyuangoumai.query.filter(yonghu_id=log.yonghuid): if not huiyuan_ids_match(rec.huiyuan_id, log.huiyuan_id): continue if not rec.jiance_shifou_daoqi() and rec.huiyuan_zhuangtai == 1: rollback_admin_still_active += 1 break for rec in Huiyuangoumai.query.all(): is_active = ( rec.huiyuan_zhuangtai == 1 and _normalize_datetime_for_compare(rec.daoqi_time) > now ) paid = _paid_orders_for(rec.yonghu_id, rec.huiyuan_id) if paid and is_active: has_live_czjilu = any( not _czjilu_entitlement_revoked(o) and _czjilu_entitlement_end(o, rec.club_id) > now for o in paid ) if not has_live_czjilu: rollback_active_orphan += 1 dingdan_id = _suspected_repair_restore(rec, since=since) if dingdan_id: row = ( rec.yonghu_id, rec.huiyuan_id, str(rec.daoqi_time), str(rec.UpdateTime), dingdan_id, '仍有效' if is_active else '已再次过期', ) suspected_all.append(row) if is_active: suspected_still_active.append(row) else: suspected_already_expired.append(row) self.stdout.write(self.style.WARNING('=== 全库规模(回答「几万条过期充值」)===')) self.stdout.write(f' huiyuangoumai 总记录数: {total_hy}') self.stdout.write(f' 当前仍有效会员记录: {active_hy}') self.stdout.write( f' 已付会员充值单且原定期限已过: {expired_czjilu_rows} 条,' f'{len(expired_czjilu_users)} 个用户', ) self.stdout.write(self.style.WARNING( '\n=== rollback 命令口径(只收「当前仍错误有效」)===', )) self.stdout.write(f' 后台已减仍有效: {rollback_admin_still_active}') self.stdout.write(f' 充值单全过期但仍有效: {rollback_active_orphan}') self.stdout.write( f' 合计约: {rollback_admin_still_active + rollback_active_orphan} 条', ) self.stdout.write(self.style.WARNING( f'\n=== 更广口径:疑似 repair 写回过(UpdateTime>={since})===', )) self.stdout.write(f' 疑似受害记录总数: {len(suspected_all)}') self.stdout.write(f' 其中当前仍有效: {len(suspected_still_active)}') self.stdout.write(f' 其中已再次过期(rollback 收不到): {len(suspected_already_expired)}') unique_users = {r[0] for r in suspected_all} self.stdout.write(f' 涉及不同用户数: {len(unique_users)}') self.stdout.write('\n样例(疑似 repair,含已再次过期):') for row in suspected_all[:sample_n]: self.stdout.write( f' 用户{row[0]} 会员{row[1]} {row[5]} ' f'到期{row[2]} 更新{row[3]} 依据单{row[4]}', ) if len(suspected_all) > sample_n: self.stdout.write(f' ... 另有 {len(suspected_all) - sample_n} 条') self.stdout.write(self.style.SUCCESS( '\n说明: 几万条过期充值单是历史正常数据;Bug 不会批量改全库,' '只在用户打开抢单池时触发。rollback 只处理「此刻仍错误有效」的记录。', ))