From c464c31140deeddd1c7b55ec8a76d713a3bbd51f Mon Sep 17 00:00:00 2001 From: XingQue Date: Wed, 8 Jul 2026 16:20:33 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8A=A0=E5=9B=BA=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=9B=9E=E6=BB=9A=E5=91=BD=E4=BB=A4=EF=BC=8C=E6=9C=89=E6=9C=AA?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=E5=85=85=E5=80=BC=E5=8D=95=E7=BB=9D=E4=B8=8D?= =?UTF-8?q?=E6=94=B6=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加 repair 指纹校验;减会员后重新付费不再误收。 Co-authored-by: Cursor --- .../commands/audit_member_bug_impact.py | 215 ++++++++++++++++++ .../rollback_erroneous_member_restore.py | 100 ++++---- jituan/services/member_recharge.py | 71 ++++++ 3 files changed, 347 insertions(+), 39 deletions(-) create mode 100644 jituan/management/commands/audit_member_bug_impact.py diff --git a/jituan/management/commands/audit_member_bug_impact.py b/jituan/management/commands/audit_member_bug_impact.py new file mode 100644 index 0000000..18c23e2 --- /dev/null +++ b/jituan/management/commands/audit_member_bug_impact.py @@ -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 只处理「此刻仍错误有效」的记录。', + )) diff --git a/jituan/management/commands/rollback_erroneous_member_restore.py b/jituan/management/commands/rollback_erroneous_member_restore.py index ebe4bf5..8ceb58c 100644 --- a/jituan/management/commands/rollback_erroneous_member_restore.py +++ b/jituan/management/commands/rollback_erroneous_member_restore.py @@ -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: diff --git a/jituan/services/member_recharge.py b/jituan/services/member_recharge.py index 2ef2091..76e809b 100644 --- a/jituan/services/member_recharge.py +++ b/jituan/services/member_recharge.py @@ -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