fix: 加固会员回滚命令,有未过期充值单绝不收回
增加 repair 指纹校验;减会员后重新付费不再误收。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
215
jituan/management/commands/audit_member_bug_impact.py
Normal file
215
jituan/management/commands/audit_member_bug_impact.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
全面审计会员 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 只处理「此刻仍错误有效」的记录。',
|
||||
))
|
||||
@@ -1,22 +1,25 @@
|
||||
"""
|
||||
收回因 dddhq 每次 sync 错误补开通的会员权益。
|
||||
收回因 dddhq 每次 sync 错误补开通的会员权益(保守策略,优先不误伤正常付费)。
|
||||
|
||||
两类处理:
|
||||
1. 后台「减会员」日志存在但权益仍有效 → 再次 revoke(删记录+标记充值单撤销)
|
||||
2. 当前仍显示有效,但已无任何未过期充值单支撑 → 置为过期
|
||||
安全规则(按优先级):
|
||||
1. 有任意未过期已付充值单支撑 → 绝不收回
|
||||
2. 无充值单的老数据 → 不自动处理
|
||||
3. 默认要求符合 bug repair 指纹(UpdateTime 写回 + 旧单原定期限已过)
|
||||
4. 后台减会员:仅当仍有效且无未过期充值单时才 revoke
|
||||
|
||||
用法:
|
||||
python manage.py rollback_erroneous_member_restore --dry-run
|
||||
python manage.py rollback_erroneous_member_restore
|
||||
python manage.py rollback_erroneous_member_restore --since 2026-06-01
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from jituan.services.huiyuan_bundle import huiyuan_ids_match
|
||||
from jituan.services.member_recharge import (
|
||||
ENTITLEMENT_REVOKED_MARKER,
|
||||
expire_huiyuangoumai_record,
|
||||
revoke_member_entitlement_admin,
|
||||
should_rollback_erroneous_membership,
|
||||
user_has_active_czjilu_entitlement,
|
||||
)
|
||||
from products.models import Huiyuangoumai
|
||||
@@ -24,7 +27,7 @@ from users.models import Xiugaijilu
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '收回错误恢复的会员(后台已减仍有效 / 充值单已过期仍显示有效)'
|
||||
help = '收回错误恢复的会员(保守:有未过期充值单绝不碰)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
@@ -32,22 +35,35 @@ class Command(BaseCommand):
|
||||
action='store_true',
|
||||
help='仅统计,不写库',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--since',
|
||||
default='2026-06-01',
|
||||
help='repair 指纹 UpdateTime 起始日(默认 2026-06-01)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-repair-signature',
|
||||
action='store_true',
|
||||
help='不要求 repair 指纹(更激进,不推荐)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = bool(options.get('dry_run'))
|
||||
since = options.get('since')
|
||||
require_sig = not bool(options.get('no_repair_signature'))
|
||||
admin_revoke = 0
|
||||
expire_orphan = 0
|
||||
skipped_safe = 0
|
||||
samples = []
|
||||
|
||||
with transaction.atomic():
|
||||
# 1) 后台减会员日志 → 仍有效的再 revoke 一遍
|
||||
seen_pairs = set()
|
||||
logs = Xiugaijilu.query.filter(
|
||||
huiyuan_id__isnull=False,
|
||||
).exclude(huiyuan_id='').order_by('-CreateTime')
|
||||
seen_pairs = set()
|
||||
|
||||
for log in logs:
|
||||
note = (log.qitashuoming or '') + (log.huiyuan_id or '')
|
||||
if '移除' not in (log.qitashuoming or '') and '减会员' not in (log.qitashuoming or ''):
|
||||
note = log.qitashuoming or ''
|
||||
if '移除' not in note and '减会员' not in note:
|
||||
continue
|
||||
if not log.yonghuid or not log.huiyuan_id:
|
||||
continue
|
||||
@@ -56,46 +72,47 @@ class Command(BaseCommand):
|
||||
continue
|
||||
seen_pairs.add(pair)
|
||||
|
||||
active = False
|
||||
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:
|
||||
active = True
|
||||
break
|
||||
if not active:
|
||||
continue
|
||||
if rec.jiance_shifou_daoqi() or rec.huiyuan_zhuangtai != 1:
|
||||
continue
|
||||
if user_has_active_czjilu_entitlement(
|
||||
rec.yonghu_id, rec.huiyuan_id, rec.club_id,
|
||||
):
|
||||
skipped_safe += 1
|
||||
continue
|
||||
ok, detail = should_rollback_erroneous_membership(
|
||||
rec, since=since, require_repair_signature=require_sig,
|
||||
)
|
||||
if not ok:
|
||||
skipped_safe += 1
|
||||
continue
|
||||
|
||||
admin_revoke += 1
|
||||
samples.append(f'[后台已减仍有效] {log.yonghuid} 会员{log.huiyuan_id}')
|
||||
if not dry_run:
|
||||
revoke_member_entitlement_admin(log.yonghuid, log.huiyuan_id)
|
||||
admin_revoke += 1
|
||||
samples.append(
|
||||
f'[后台已减仍有效] {log.yonghuid} 会员{log.huiyuan_id} '
|
||||
f'依据单{detail or "-"}',
|
||||
)
|
||||
if not dry_run:
|
||||
revoke_member_entitlement_admin(log.yonghuid, log.huiyuan_id)
|
||||
break
|
||||
|
||||
# 2) 仍显示有效,但没有任何未过期充值单支撑 → 过期收回
|
||||
for rec in Huiyuangoumai.query.filter(huiyuan_zhuangtai=1):
|
||||
if rec.jiance_shifou_daoqi():
|
||||
continue
|
||||
from products.models import Czjilu
|
||||
from jituan.services.member_recharge import MEMBER_LEIXING, MEMBER_PAID_STATUS
|
||||
ok, detail = should_rollback_erroneous_membership(
|
||||
rec, since=since, require_repair_signature=require_sig,
|
||||
)
|
||||
if not ok:
|
||||
if detail == '有未过期充值单支撑':
|
||||
skipped_safe += 1
|
||||
continue
|
||||
|
||||
paid_orders = [
|
||||
o for o in Czjilu.query.filter(
|
||||
yonghuid=rec.yonghu_id,
|
||||
leixing=MEMBER_LEIXING,
|
||||
zhuangtai=MEMBER_PAID_STATUS,
|
||||
)
|
||||
if huiyuan_ids_match(o.huiyuan_id, rec.huiyuan_id)
|
||||
]
|
||||
if not paid_orders:
|
||||
continue
|
||||
if user_has_active_czjilu_entitlement(
|
||||
rec.yonghu_id, rec.huiyuan_id, rec.club_id,
|
||||
):
|
||||
continue
|
||||
expire_orphan += 1
|
||||
samples.append(
|
||||
f'[充值单已过期仍有效] {rec.yonghu_id} 会员{rec.huiyuan_id} '
|
||||
f'到期{rec.daoqi_time}',
|
||||
f'到期{rec.daoqi_time} 依据单{detail or "-"}',
|
||||
)
|
||||
if not dry_run:
|
||||
expire_huiyuangoumai_record(
|
||||
@@ -109,8 +126,13 @@ class Command(BaseCommand):
|
||||
mode = 'DRY-RUN' if dry_run else 'APPLIED'
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'[{mode}] 后台减会员仍有效收回 {admin_revoke} 条;'
|
||||
f'无有效充值单支撑过期 {expire_orphan} 条',
|
||||
f'无有效充值单支撑过期 {expire_orphan} 条;'
|
||||
f'安全跳过 {skipped_safe} 条(含正常付费/不符合指纹)',
|
||||
))
|
||||
if require_sig:
|
||||
self.stdout.write(' 已启用 repair 指纹校验(推荐)')
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING(' 未启用 repair 指纹(激进模式,慎用)'))
|
||||
for line in samples[:50]:
|
||||
self.stdout.write(f' {line}')
|
||||
if len(samples) > 50:
|
||||
|
||||
@@ -673,6 +673,77 @@ def user_has_active_czjilu_entitlement(yonghu_id, huiyuan_id, club_id=None):
|
||||
return False
|
||||
|
||||
|
||||
def _paid_member_orders_for(yonghu_id, huiyuan_id):
|
||||
from jituan.services.huiyuan_bundle import huiyuan_ids_match
|
||||
|
||||
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 order_purchase_days_locked(order, club_id=None):
|
||||
"""充值单锁定的有效天数(与履约/repair 同源)。"""
|
||||
cid = getattr(order, 'club_id', None) or club_id or CLUB_ID_DEFAULT
|
||||
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 is_suspected_bug_repair_restore(rec, since=None):
|
||||
"""
|
||||
Bug repair 指纹:UpdateTime 时将 daoqi 设为 UpdateTime+days,
|
||||
且存在匹配充值单的原定期限在 UpdateTime 之前已结束。
|
||||
正常续费:必有未过期充值单支撑,或 daoqi 与充值单原定期限一致。
|
||||
"""
|
||||
from jituan.services.huiyuan_bundle import _normalize_datetime_for_compare
|
||||
|
||||
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_days = (daoqi - updated).days
|
||||
if gap_days < 1 or gap_days > 400:
|
||||
return None
|
||||
|
||||
for order in _paid_member_orders_for(rec.yonghu_id, rec.huiyuan_id):
|
||||
if _czjilu_entitlement_revoked(order):
|
||||
continue
|
||||
days = order_purchase_days_locked(order, rec.club_id)
|
||||
if abs(days - gap_days) > 2:
|
||||
continue
|
||||
if _czjilu_entitlement_end(order, rec.club_id) < updated:
|
||||
return order.dingdan_id
|
||||
return None
|
||||
|
||||
|
||||
def should_rollback_erroneous_membership(rec, since=None, require_repair_signature=True):
|
||||
"""
|
||||
是否应收回错误恢复的会员(保守策略,优先不误伤正常付费用户)。
|
||||
|
||||
硬性豁免:任意未过期已付充值单支撑 → 绝不收回。
|
||||
"""
|
||||
if rec.jiance_shifou_daoqi() or rec.huiyuan_zhuangtai != 1:
|
||||
return False, None
|
||||
if user_has_active_czjilu_entitlement(rec.yonghu_id, rec.huiyuan_id, rec.club_id):
|
||||
return False, '有未过期充值单支撑'
|
||||
orders = _paid_member_orders_for(rec.yonghu_id, rec.huiyuan_id)
|
||||
if not orders:
|
||||
return False, '无充值单不自动处理'
|
||||
if require_repair_signature:
|
||||
dingdan_id = is_suspected_bug_repair_restore(rec, since=since)
|
||||
if not dingdan_id:
|
||||
return False, '不符合 bug repair 指纹'
|
||||
return True, dingdan_id
|
||||
return True, None
|
||||
|
||||
|
||||
def expire_huiyuangoumai_record(rec, reason=''):
|
||||
"""将会员记录置为已过期(不删行,便于审计)。"""
|
||||
from jituan.services.huiyuan_bundle import _normalize_datetime_for_compare
|
||||
|
||||
Reference in New Issue
Block a user