Files
Django/jituan/management/commands/audit_member_bug_impact.py
2026-07-08 16:26:19 +08:00

218 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
全面审计会员 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 只处理「此刻仍错误有效」的记录。',
))