216 lines
8.0 KiB
Python
216 lines
8.0 KiB
Python
"""
|
||
全面审计会员 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,
|
||
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)
|
||
if since and updated < _normalize_datetime_for_compare(since):
|
||
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 只处理「此刻仍错误有效」的记录。',
|
||
))
|