From 7dc78acb73ab186248cbfa4e3d1a2dbaaaa34b2e Mon Sep 17 00:00:00 2001 From: XingQue Date: Wed, 8 Jul 2026 00:07:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=9A=E5=91=98=E5=85=85=E5=80=BC?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E5=8A=A0=E5=9B=BA=EF=BC=88=E4=BD=93=E9=AA=8C?= =?UTF-8?q?=E8=B5=84=E6=A0=BC=E3=80=81=E7=A7=AF=E5=88=86=E5=88=86=E7=BA=A2?= =?UTF-8?q?=E3=80=81czjilu=E5=AE=A1=E8=AE=A1=E3=80=81=E4=BD=99=E9=A2=9D?= =?UTF-8?q?=E6=8A=B5=E6=89=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../commands/backfill_member_trial_flags.py | 52 +++ jituan/services/member_recharge.py | 374 ++++++++++++++---- jituan/services/member_recharge_records.py | 6 + products/views/chongzhi_payment.py | 75 +++- products/views/huiyuan_payment.py | 17 +- products/views/product_query.py | 35 +- 6 files changed, 428 insertions(+), 131 deletions(-) create mode 100644 jituan/management/commands/backfill_member_trial_flags.py diff --git a/jituan/management/commands/backfill_member_trial_flags.py b/jituan/management/commands/backfill_member_trial_flags.py new file mode 100644 index 0000000..0301192 --- /dev/null +++ b/jituan/management/commands/backfill_member_trial_flags.py @@ -0,0 +1,52 @@ +"""回填会员体验标记:czjilu.is_trial + huiyuangoumai.has_used_trial。""" +from django.core.management.base import BaseCommand +from django.db import transaction + +from jituan.services.member_recharge import ( + _order_is_trial_like, + has_paid_trial_purchase, +) +from products.models import Czjilu, Huiyuangoumai + + +class Command(BaseCommand): + help = '回填体验会员订单/权益标记(修复重复购买体验、正式计次错误)' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='仅统计,不写库', + ) + + def handle(self, *args, **options): + dry_run = bool(options.get('dry_run')) + czjilu_fixed = 0 + goumai_fixed = 0 + + member_orders = Czjilu.query.filter(leixing=1, zhuangtai=3) + with transaction.atomic(): + for order in member_orders.iterator(): + club_id = getattr(order, 'club_id', None) + if _order_is_trial_like(order, club_id) and not order.is_trial: + czjilu_fixed += 1 + if not dry_run: + Czjilu.query.filter(dingdan_id=order.dingdan_id).update(is_trial=True) + + goumai_rows = Huiyuangoumai.query.all() + for row in goumai_rows.iterator(): + if row.has_used_trial: + continue + if has_paid_trial_purchase(row.yonghu_id, row.huiyuan_id, row.club_id): + goumai_fixed += 1 + if not dry_run: + Huiyuangoumai.query.filter(id=row.id).update(has_used_trial=True) + + if dry_run: + transaction.set_rollback(True) + + mode = 'DRY-RUN' if dry_run else 'APPLIED' + self.stdout.write(self.style.SUCCESS( + f'[{mode}] czjilu.is_trial 修复 {czjilu_fixed} 条,' + f'huiyuangoumai.has_used_trial 修复 {goumai_fixed} 条', + )) diff --git a/jituan/services/member_recharge.py b/jituan/services/member_recharge.py index 06ff134..b8effac 100644 --- a/jituan/services/member_recharge.py +++ b/jituan/services/member_recharge.py @@ -2,11 +2,12 @@ 会员微信充值履约(回调 / 前端确认共用,幂等)。 分红策略(定稿 v2): -- formal_cishu:本俱乐部 + 打手 + 会员,czjilu 已支付正式单(is_trial=false)笔数 + 1。 - 与换管事无关;体验单不计入。 +- formal_cishu:本俱乐部 + 打手 + 会员,已支付正式单笔数 + 1(体验单及历史脏数据不计入)。 - formal_cishu=1:duoci_fenhong 或 club 默认正式分成。 - formal_cishu>=2:仅 duoci_fenhong;无配置则不分。 -- 体验单:固定 trial_guanshifc / trial_zuzhangfc;不计入 formal_cishu。 +- 体验单:固定 trial_guanshifc / trial_zuzhangfc;终身仅可购 1 次。 +- 积分:体验 +5,正式 +5(体验后)或直接至 10(未买过体验),封顶 10。 +- 履约(回调/轮询/余额)均二次校验体验资格;分红失败回滚事务。 财务:每笔成功履约必须 czjilu.zhuangtai=3 + 收支入账(record_wechat_income_once)。 """ @@ -45,6 +46,11 @@ MEMBER_FENHONG_LEIXING = 1 MEMBER_LEIXING = 1 MEMBER_PAID_STATUS = 3 MEMBER_PENDING_STATUS = 9 +MEMBER_FAILED_STATUS = 8 +MEMBER_CANCELLED_STATUS = 2 +MEMBER_JIFEN_CAP = 10 +MEMBER_JIFEN_TRIAL_GRANT = 5 +MEMBER_JIFEN_FORMAL_GRANT = 5 class MemberRechargeError(Exception): @@ -84,17 +90,70 @@ def club_huiyuan_sellable(club_id, huiyuan_id, is_trial=False): return True, price, days -def has_formal_member_purchase(yonghuid, huiyuan_id, club_id): - """是否已有成功支付的正式会员单(用于禁止再买体验)。""" +def _order_is_trial_like(order, club_id=None): + """识别体验单(含历史 is_trial 未回填的脏数据)。""" + if bool(getattr(order, 'is_trial', False)): + return True + club_id = club_id or getattr(order, 'club_id', None) or CLUB_ID_DEFAULT + huiyuan_id = getattr(order, 'huiyuan_id', None) + if not huiyuan_id: + return False + row = get_club_huiyuan_row(club_id, huiyuan_id, require_enabled=False) + if not row or not row.trial_enabled: + return False + trial_price = Decimal(str(row.trial_price or 0)) + trial_days = int(row.trial_days or 0) + if trial_price <= 0 or trial_days <= 0: + return False + order_jine = Decimal(str(order.jine or 0)) + if abs(order_jine - trial_price) > Decimal('0.01'): + return False + order_days = int(getattr(order, 'purchase_days', None) or 0) + return order_days in (0, trial_days) + + +def _iter_paid_member_orders(yonghuid, huiyuan_id, club_id, exclude_dingdan_id=None): club_id = club_id or CLUB_ID_DEFAULT - return Czjilu.query.filter( + qs = Czjilu.query.filter( yonghuid=yonghuid, huiyuan_id=huiyuan_id, leixing=MEMBER_LEIXING, zhuangtai=MEMBER_PAID_STATUS, club_id=club_id, - is_trial=False, - ).exists() + ) + if exclude_dingdan_id: + qs = qs.exclude(dingdan_id=exclude_dingdan_id) + return qs + + +def has_paid_trial_purchase(yonghuid, huiyuan_id, club_id, exclude_dingdan_id=None): + """是否已有成功支付体验单(含历史脏数据)。""" + club_id = club_id or CLUB_ID_DEFAULT + for order in _iter_paid_member_orders(yonghuid, huiyuan_id, club_id, exclude_dingdan_id): + if _order_is_trial_like(order, club_id): + return True + return False + + +def has_formal_member_purchase(yonghuid, huiyuan_id, club_id, exclude_dingdan_id=None): + """是否已有成功支付的正式会员单(用于禁止再买体验)。""" + club_id = club_id or CLUB_ID_DEFAULT + for order in _iter_paid_member_orders(yonghuid, huiyuan_id, club_id, exclude_dingdan_id): + if not _order_is_trial_like(order, club_id): + return True + return False + + +def _sync_trial_usage_flags(yonghuid, huiyuan_id, club_id): + """根据 czjilu 历史回填 huiyuangoumai.has_used_trial。""" + goumai = Huiyuangoumai.query.filter( + yonghu_id=yonghuid, huiyuan_id=huiyuan_id, + ).first() + if not goumai: + return + if has_paid_trial_purchase(yonghuid, huiyuan_id, club_id) and not goumai.has_used_trial: + goumai.has_used_trial = True + goumai.save(update_fields=['has_used_trial', 'UpdateTime']) def can_purchase_trial(yonghuid, huiyuan_id, club_id): @@ -104,6 +163,11 @@ def can_purchase_trial(yonghuid, huiyuan_id, club_id): if not sellable: return False, '本俱乐部未开启体验会员' + _sync_trial_usage_flags(yonghuid, huiyuan_id, club_id) + + if has_paid_trial_purchase(yonghuid, huiyuan_id, club_id): + return False, '体验会员仅可购买一次' + goumai = Huiyuangoumai.query.filter( yonghu_id=yonghuid, huiyuan_id=huiyuan_id, ).first() @@ -137,20 +201,61 @@ def validate_member_purchase(yonghuid, huiyuan_id, club_id, is_trial): def resolve_formal_purchase_cishu(yonghuid, huiyuan_id, current_dingdan_id, club_id): """ 打手第几次正式购买该会员(duoci_fenhong.cishu)。 - 仅统计 is_trial=false 的已支付 czjilu。 + 体验单(含历史脏数据)不计入。 """ club_id = club_id or CLUB_ID_DEFAULT - prior_paid = Czjilu.query.filter( - yonghuid=yonghuid, - huiyuan_id=huiyuan_id, - leixing=MEMBER_LEIXING, - zhuangtai=MEMBER_PAID_STATUS, - club_id=club_id, - is_trial=False, - ).exclude(dingdan_id=current_dingdan_id).count() + prior_paid = 0 + for order in _iter_paid_member_orders(yonghuid, huiyuan_id, club_id, current_dingdan_id): + if not _order_is_trial_like(order, club_id): + prior_paid += 1 return prior_paid + 1 +def _truncate_shuoming(text, max_len=100): + text = (text or '').strip() + return text[:max_len] if text else '' + + +def _mark_member_order_failed(order, reason, *, record_income=False, club_id=None): + """已收款或已扣款但履约失败:写失败状态,不发会员/积分/分红。""" + club_id = club_id or getattr(order, 'club_id', None) or CLUB_ID_DEFAULT + base = (order.shuoming or '').strip() + order.zhuangtai = MEMBER_FAILED_STATUS + order.shuoming = _truncate_shuoming(f'{base} | 履约失败:{reason}') + order.save(update_fields=['zhuangtai', 'shuoming', 'UpdateTime']) + if record_income: + try: + _record_member_income(order, club_id) + except MemberRechargeError: + logger.error('失败单记收入异常 dingdan=%s', order.dingdan_id, exc_info=True) + logger.error('会员订单履约失败 dingdan=%s reason=%s', order.dingdan_id, reason) + + +def _require_paid_order_for_benefits(order): + """仅已支付(3)订单可发会员权益/积分/分红。""" + if int(getattr(order, 'zhuangtai', 0) or 0) != MEMBER_PAID_STATUS: + raise MemberRechargeError( + f'订单未支付成功(zhuangtai={order.zhuangtai}),禁止发放权益', + 'not_paid', + ) + + +def _validate_member_fulfillment(order, club_id): + """履约前二次校验(防止待支付旧单绕过下单校验)。""" + club_id = club_id or CLUB_ID_DEFAULT + is_trial = bool(getattr(order, 'is_trial', False)) + if is_trial or _order_is_trial_like(order, club_id): + ok, msg = can_purchase_trial(order.yonghuid, order.huiyuan_id, club_id) + if not ok: + raise MemberRechargeError(msg or '体验会员不可购买', 'trial_not_allowed') + return + ok, msg, _, _ = validate_member_purchase( + order.yonghuid, order.huiyuan_id, club_id, is_trial=False, + ) + if not ok: + raise MemberRechargeError(msg or '会员不可购买', 'purchase_not_allowed') + + # 兼容旧名 def resolve_member_purchase_cishu(yonghuid, huiyuan_id, current_dingdan_id, club_id): return resolve_formal_purchase_cishu(yonghuid, huiyuan_id, current_dingdan_id, club_id) @@ -160,50 +265,54 @@ def fulfill_member_balance_purchase(czjilu, huiyuan, club_id=None): """余额抵扣购买会员:按俱乐部配置天数履约,并计入购买次数/分红。""" club_id = club_id or getattr(czjilu, 'club_id', None) or CLUB_ID_DEFAULT is_trial = bool(getattr(czjilu, 'is_trial', False)) - purchase_days = int(getattr(czjilu, 'purchase_days', None) or 0) - if purchase_days <= 0: - _, _, purchase_days = club_huiyuan_sellable(club_id, czjilu.huiyuan_id, is_trial=is_trial) - - formal_cishu = None - trial_guanshi_fc = Decimal('0') - trial_zuzhangfc = Decimal('0') if is_trial: - row = get_club_huiyuan_row(club_id, czjilu.huiyuan_id) - if row: - trial_guanshi_fc = Decimal(str(row.trial_guanshifc or 0)) - trial_zuzhangfc = Decimal(str(row.trial_zuzhangfc or 0)) - else: - formal_cishu = resolve_formal_purchase_cishu( - czjilu.yonghuid, czjilu.huiyuan_id, czjilu.dingdan_id, club_id, + raise MemberRechargeError( + '体验会员仅支持微信支付,不可用佣金、分红或押金抵扣', + 'trial_balance_forbidden', ) - goumai_record, is_first_buy_any = _apply_huiyuan_entitlement( - czjilu.yonghuid, czjilu.huiyuan_id, huiyuan, club_id, is_trial, purchase_days, + ok, err_msg, _, _ = validate_member_purchase( + czjilu.yonghuid, czjilu.huiyuan_id, club_id, is_trial=False, ) - if is_first_buy_any: - _add_first_buy_jifen(czjilu.yonghuid) + if not ok: + raise MemberRechargeError(err_msg or '会员不可购买', 'purchase_not_allowed') + + purchase_days = int(getattr(czjilu, 'purchase_days', None) or 0) + if purchase_days <= 0: + _, _, purchase_days = club_huiyuan_sellable(club_id, czjilu.huiyuan_id, is_trial=False) + + formal_cishu = resolve_formal_purchase_cishu( + czjilu.yonghuid, czjilu.huiyuan_id, czjilu.dingdan_id, club_id, + ) + trial_guanshi_fc = Decimal('0') + trial_zuzhangfc = Decimal('0') czjilu.zhuangtai = MEMBER_PAID_STATUS if not czjilu.purchase_days: czjilu.purchase_days = purchase_days - czjilu.save(update_fields=['zhuangtai', 'purchase_days']) + czjilu.is_trial = False + czjilu.save(update_fields=['zhuangtai', 'purchase_days', 'is_trial']) + _require_paid_order_for_benefits(czjilu) + + goumai_record, _ = _apply_huiyuan_entitlement( + czjilu.yonghuid, czjilu.huiyuan_id, huiyuan, club_id, False, purchase_days, + ) + new_jifen = _apply_member_purchase_jifen( + czjilu.yonghuid, is_trial=False, huiyuan_id=czjilu.huiyuan_id, + club_id=club_id, current_dingdan_id=czjilu.dingdan_id, + ) huiyuan.goumai_cishu = (huiyuan.goumai_cishu or 0) + 1 huiyuan.save(update_fields=['goumai_cishu']) - try: - _apply_member_fenhong( - czjilu, huiyuan, - formal_cishu=formal_cishu, - trial_guanshi_fc=trial_guanshi_fc, - trial_zuzhangfc=trial_zuzhangfc, - ) - except Exception as exc: - logger.error( - '余额购会员分红失败 dingdan=%s: %s', czjilu.dingdan_id, exc, exc_info=True, - ) + _apply_member_fenhong( + czjilu, huiyuan, + formal_cishu=formal_cishu, + trial_guanshi_fc=trial_guanshi_fc, + trial_zuzhangfc=trial_zuzhangfc, + ) - return goumai_record, is_first_buy_any + return goumai_record, new_jifen def _lookup_duoci_fenhong(club_id, huiyuan_id, beneficiary_id, cishu, field): @@ -313,6 +422,8 @@ def _apply_huiyuan_entitlement(yonghuid, huiyuan_id, huiyuan, club_id, is_trial, existing.has_used_trial = True else: existing.is_trial = False + if has_paid_trial_purchase(yonghuid, huiyuan_id, club_id): + existing.has_used_trial = True existing.save(update_fields=[ 'daoqi_time', 'huiyuan_zhuangtai', 'jieshao', 'is_trial', 'has_used_trial', 'UpdateTime', ]) @@ -321,6 +432,7 @@ def _apply_huiyuan_entitlement(yonghuid, huiyuan_id, huiyuan, club_id, is_trial, other = Huiyuangoumai.query.filter(yonghu_id=yonghuid).exclude(huiyuan_id=huiyuan_id).count() if other == 0: is_first_buy_any = True + used_trial_before = has_paid_trial_purchase(yonghuid, huiyuan_id, club_id) resolved_club = club_id or get_user_club_id(User.query.filter(UserUID=yonghuid).first()) record = Huiyuangoumai.query.create( yonghu_id=yonghuid, @@ -330,12 +442,18 @@ def _apply_huiyuan_entitlement(yonghuid, huiyuan_id, huiyuan, club_id, is_trial, daoqi_time=new_daoqi, club_id=resolved_club, is_trial=is_trial, - has_used_trial=is_trial, + has_used_trial=is_trial or used_trial_before, ) return record, is_first_buy_any def _apply_member_fenhong(order, huiyuan, formal_cishu=None, trial_guanshi_fc=None, trial_zuzhang_fc=None): + if int(getattr(order, 'zhuangtai', 0) or 0) != MEMBER_PAID_STATUS: + logger.warning( + '订单未支付成功,禁止分红 dingdan=%s zhuangtai=%s', + order.dingdan_id, order.zhuangtai, + ) + return if Gsfenhong.query.filter(dingdan_id=order.dingdan_id).exists(): logger.info('会员分红已处理,跳过: %s', order.dingdan_id) return @@ -368,7 +486,7 @@ def _apply_member_fenhong(order, huiyuan, formal_cishu=None, trial_guanshi_fc=No club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT huiyuan_id = huiyuan.huiyuan_id order_amount = Decimal(str(order.jine)) - is_trial_order = bool(getattr(order, 'is_trial', False)) + is_trial_order = _order_is_trial_like(order, club_id) if is_trial_order: guanshi_fenhong = Decimal(str(trial_guanshi_fc or 0)) @@ -460,13 +578,55 @@ def _apply_member_fenhong(order, huiyuan, formal_cishu=None, trial_guanshi_fc=No ) -def _add_first_buy_jifen(yonghuid): +def _apply_member_purchase_jifen(yonghuid, is_trial, huiyuan_id, club_id, current_dingdan_id=None): + """ + 会员购积分:体验 +5,正式 +5(体验后)或直接到 10(未买过体验),封顶 10。 + """ try: dashou = UserDashou.query.get(user__UserUID=yonghuid) - dashou.jifen += 10 - dashou.save(update_fields=['jifen']) except UserDashou.DoesNotExist: - logger.warning('首次购会员加积分失败,打手不存在: %s', yonghuid) + logger.warning('会员购积分失败,打手不存在: %s', yonghuid) + return 0 + + current = int(dashou.jifen or 0) + if current >= MEMBER_JIFEN_CAP: + return current + + if is_trial: + delta = min(MEMBER_JIFEN_TRIAL_GRANT, MEMBER_JIFEN_CAP - current) + else: + had_trial = has_paid_trial_purchase( + yonghuid, huiyuan_id, club_id, exclude_dingdan_id=current_dingdan_id, + ) + if had_trial: + delta = min(MEMBER_JIFEN_FORMAL_GRANT, MEMBER_JIFEN_CAP - current) + else: + delta = MEMBER_JIFEN_CAP - current + + if delta <= 0: + return current + + dashou.jifen = min(current + delta, MEMBER_JIFEN_CAP) + dashou.save(update_fields=['jifen']) + logger.info( + '会员购积分 yonghu=%s is_trial=%s huiyuan=%s +%s -> %s', + yonghuid, is_trial, huiyuan_id, delta, dashou.jifen, + ) + return dashou.jifen + + +def _add_first_buy_jifen(yonghuid): + """兼容旧调用:等价于直接买正式且从未购会员积分。""" + logger.warning('_add_first_buy_jifen 已废弃,请改用 _apply_member_purchase_jifen') + try: + dashou = UserDashou.query.get(user__UserUID=yonghuid) + except UserDashou.DoesNotExist: + return + current = int(dashou.jifen or 0) + if current >= MEMBER_JIFEN_CAP: + return + dashou.jifen = MEMBER_JIFEN_CAP + dashou.save(update_fields=['jifen']) def repair_member_entitlement_if_paid(dingdan_id): @@ -488,11 +648,34 @@ def repair_member_entitlement_if_paid(dingdan_id): club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT days = order.purchase_days or 30 + is_trial = _order_is_trial_like(order, club_id) with transaction.atomic(): _apply_huiyuan_entitlement( - order.yonghuid, order.huiyuan_id, huiyuan, club_id, - bool(getattr(order, 'is_trial', False)), days, + order.yonghuid, order.huiyuan_id, huiyuan, club_id, is_trial, days, ) + _apply_member_purchase_jifen( + order.yonghuid, is_trial=is_trial, huiyuan_id=order.huiyuan_id, + club_id=club_id, current_dingdan_id=order.dingdan_id, + ) + if not Gsfenhong.query.filter(dingdan_id=dingdan_id, fenhong_leixing=MEMBER_FENHONG_LEIXING).exists(): + trial_guanshi_fc = Decimal('0') + trial_zuzhang_fc = Decimal('0') + formal_cishu = None + if is_trial: + row = get_club_huiyuan_row(club_id, order.huiyuan_id) + if row: + trial_guanshi_fc = Decimal(str(row.trial_guanshifc or 0)) + trial_zuzhang_fc = Decimal(str(row.trial_zuzhangfc or 0)) + else: + formal_cishu = resolve_formal_purchase_cishu( + order.yonghuid, order.huiyuan_id, order.dingdan_id, club_id, + ) + _apply_member_fenhong( + order, huiyuan, + formal_cishu=formal_cishu, + trial_guanshi_fc=trial_guanshi_fc, + trial_zuzhang_fc=trial_zuzhang_fc, + ) logger.warning('已补开通会员 dingdan=%s', dingdan_id) return True @@ -536,7 +719,21 @@ def fulfill_member_recharge(dingdan_id, paid_amount_yuan=None, source='callback' raise MemberRechargeError(f'会员不存在: {order.huiyuan_id}', 'huiyuan_not_found') club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT - is_trial = bool(getattr(order, 'is_trial', False)) + try: + _validate_member_fulfillment(order, club_id) + except MemberRechargeError as exc: + _mark_member_order_failed( + order, str(exc), + record_income=paid_amount_yuan is not None, + club_id=club_id, + ) + raise + + is_trial = _order_is_trial_like(order, club_id) + if is_trial and not order.is_trial: + order.is_trial = True + order.save(update_fields=['is_trial']) + purchase_days = order.purchase_days or (30 if not is_trial else 0) if purchase_days <= 0: _, _, purchase_days = club_huiyuan_sellable(club_id, order.huiyuan_id, is_trial=is_trial) @@ -554,31 +751,33 @@ def fulfill_member_recharge(dingdan_id, paid_amount_yuan=None, source='callback' order.yonghuid, order.huiyuan_id, order.dingdan_id, club_id, ) - goumai_record, is_first_buy_any = _apply_huiyuan_entitlement( + order.zhuangtai = MEMBER_PAID_STATUS + if not order.purchase_days: + order.purchase_days = purchase_days + if is_trial: + order.is_trial = True + order.save(update_fields=['zhuangtai', 'purchase_days', 'is_trial']) + _require_paid_order_for_benefits(order) + + goumai_record, _ = _apply_huiyuan_entitlement( order.yonghuid, order.huiyuan_id, huiyuan, club_id, is_trial, purchase_days, ) - if is_first_buy_any: - _add_first_buy_jifen(order.yonghuid) - - order.zhuangtai = MEMBER_PAID_STATUS - order.save(update_fields=['zhuangtai']) + _apply_member_purchase_jifen( + order.yonghuid, is_trial=is_trial, huiyuan_id=order.huiyuan_id, + club_id=club_id, current_dingdan_id=order.dingdan_id, + ) huiyuan.goumai_cishu = (huiyuan.goumai_cishu or 0) + 1 huiyuan.save(update_fields=['goumai_cishu']) _record_member_income(order, club_id) - try: - _apply_member_fenhong( - order, huiyuan, - formal_cishu=formal_cishu, - trial_guanshi_fc=trial_guanshi_fc, - trial_zuzhang_fc=trial_zuzhang_fc, - ) - except Exception as exc: - logger.error( - '会员分红失败但会员已开通 dingdan=%s err=%s', dingdan_id, exc, exc_info=True, - ) + _apply_member_fenhong( + order, huiyuan, + formal_cishu=formal_cishu, + trial_guanshi_fc=trial_guanshi_fc, + trial_zuzhang_fc=trial_zuzhang_fc, + ) logger.info( '会员充值履约完成 dingdan=%s source=%s club=%s is_trial=%s formal_cishu=%s jine=%s', @@ -689,14 +888,37 @@ def dashou_in_active_trial_period(yonghuid): ).exists() -def extend_member_entitlement_admin(yonghuid, huiyuan_id, days, club_id=None): - """后台赠送正式会员(无 czjilu、无分红)。""" +def extend_member_entitlement_admin(yonghuid, huiyuan_id, days, club_id=None, operator_note=''): + """后台赠送正式会员:写 czjilu 审计记录,无支付、无分红、无积分。""" try: huiyuan = Huiyuan.query.get(huiyuan_id=huiyuan_id) except Huiyuan.DoesNotExist: return None, '会员类型不存在' days = max(1, int(days)) + club_id = club_id or CLUB_ID_DEFAULT record, _ = _apply_huiyuan_entitlement( yonghuid, huiyuan_id, huiyuan, club_id, is_trial=False, purchase_days=days, ) + + timestamp = str(int(timezone.now().timestamp() * 1000))[-12:] + rand_str = ''.join(random.choices(string.ascii_uppercase + string.digits, k=4)) + dingdan_id = f'ADM{timestamp}{rand_str}' + note = (operator_note or '').strip() + shuoming = f'后台赠送会员 {huiyuan.jieshao} {days}天' + if note: + shuoming = _truncate_shuoming(f'{shuoming} | {note}') + + Czjilu.query.create( + dingdan_id=dingdan_id, + zhuangtai=MEMBER_PAID_STATUS, + yonghuid=yonghuid, + jine=Decimal('0'), + leixing=MEMBER_LEIXING, + shuoming=shuoming, + huiyuan_id=huiyuan_id, + club_id=club_id, + is_trial=False, + purchase_days=days, + ) + logger.info('后台赠送会员 yonghu=%s huiyuan=%s days=%s dingdan=%s', yonghuid, huiyuan_id, days, dingdan_id) return record, None diff --git a/jituan/services/member_recharge_records.py b/jituan/services/member_recharge_records.py index 8d2eb34..8fa6880 100644 --- a/jituan/services/member_recharge_records.py +++ b/jituan/services/member_recharge_records.py @@ -13,6 +13,8 @@ from jituan.services.member_recharge import ( MEMBER_LEIXING, MEMBER_PAID_STATUS, MEMBER_PENDING_STATUS, + MEMBER_FAILED_STATUS, + MEMBER_CANCELLED_STATUS, resolve_formal_purchase_cishu, ) from jituan.constants import DATA_SCOPE_ALL @@ -22,6 +24,8 @@ from users.models import UserDashou, UserBoss MEMBER_STATUS_LABELS = { MEMBER_PAID_STATUS: '已支付', MEMBER_PENDING_STATUS: '待支付', + MEMBER_FAILED_STATUS: '履约失败', + MEMBER_CANCELLED_STATUS: '已取消', } @@ -300,6 +304,8 @@ def build_member_recharge_records_payload(request, filters=None, page=1, page_si 'zhuangtai_options': [ {'value': MEMBER_PAID_STATUS, 'label': '已支付'}, {'value': MEMBER_PENDING_STATUS, 'label': '待支付'}, + {'value': MEMBER_FAILED_STATUS, 'label': '履约失败'}, + {'value': MEMBER_CANCELLED_STATUS, 'label': '已取消'}, ], }, 'scope': resolve_club_scope(request), diff --git a/products/views/chongzhi_payment.py b/products/views/chongzhi_payment.py index f8eb5df..2e0a3b9 100644 --- a/products/views/chongzhi_payment.py +++ b/products/views/chongzhi_payment.py @@ -879,6 +879,7 @@ class DsqrgmdhView(APIView): permission_classes = [permissions.IsAuthenticated] def post(self, request): + from jituan.services.member_recharge import MemberRechargeError try: leixing = request.data.get('leixing') shenfen_id = request.data.get('shenfen_id') @@ -969,6 +970,7 @@ class DsqrgmdhView(APIView): return Response({'code': 400, 'msg': f'{identity["name"]}余额不足,需要{required}元,当前{balance}元'}, status=status.HTTP_400_BAD_REQUEST) try: + fulfill_error = None with transaction.atomic(): locked_profile = identity['model'].objects.select_for_update().get(id=profile.id) current_balance = getattr(locked_profile, identity['balance_field']) @@ -995,7 +997,8 @@ class DsqrgmdhView(APIView): shuoming = f"{identity['name']}抵扣 {action_desc},抵扣金额{required}元" cz_leixing = leixing cz_club_id = club_id - cz_zhuangtai = 1 + from jituan.services.member_recharge import MEMBER_PENDING_STATUS, MEMBER_FAILED_STATUS, MEMBER_PAID_STATUS + cz_zhuangtai = MEMBER_PENDING_STATUS if leixing == 4: from jituan.services.club_penalty import penalty_cz_club_id from products.czjilu_types import ( @@ -1031,35 +1034,52 @@ class DsqrgmdhView(APIView): czjilu = Czjilu.query.create(**czjilu_kwargs) - response_data = {} + response_data = {'dingdan_id': dingdan_id} + fulfill_error = None if leixing == 1: - from jituan.services.member_recharge import fulfill_member_balance_purchase - huiyuan_record, is_first_buy = fulfill_member_balance_purchase( - czjilu, huiyuan, club_id=club_id, + from jituan.services.member_recharge import ( + fulfill_member_balance_purchase, + validate_member_purchase, ) - response_data['huiyuan'] = { - 'huiyuanid': huiyuan_id, - 'huiyuanming': huiyuan.jieshao, - 'daoqi': huiyuan_record.daoqi_time.strftime('%Y-%m-%d %H:%M:%S') if hasattr(huiyuan_record, 'daoqi_time') else '', - } - if is_first_buy: - try: - dashou = UserDashou.query.get(user=user) - dashou.jifen += 10 - dashou.save(update_fields=['jifen']) - response_data['jifen'] = dashou.jifen - except UserDashou.DoesNotExist: - pass + try: + ok, err_msg, _, _ = validate_member_purchase( + yonghuid, huiyuan_id, club_id, is_trial=False, + ) + if not ok: + raise MemberRechargeError(err_msg or '会员不可购买', 'purchase_not_allowed') + huiyuan_record, new_jifen = fulfill_member_balance_purchase( + czjilu, huiyuan, club_id=club_id, + ) + response_data['huiyuan'] = { + 'huiyuanid': huiyuan_id, + 'huiyuanming': huiyuan.jieshao, + 'daoqi': huiyuan_record.daoqi_time.strftime('%Y-%m-%d %H:%M:%S') if hasattr(huiyuan_record, 'daoqi_time') else '', + } + response_data['jifen'] = new_jifen + except MemberRechargeError as exc: + setattr(locked_profile, identity['balance_field'], current_balance) + locked_profile.save(update_fields=[identity['balance_field']]) + czjilu.zhuangtai = MEMBER_FAILED_STATUS + czjilu.shuoming = f"{shuoming} | 履约失败:{str(exc)[:40]}"[:100] + czjilu.save(update_fields=['zhuangtai', 'shuoming']) + fulfill_error = exc elif leixing == 2: try: dashou = UserDashou.objects.select_for_update().get(user=user) dashou.yajin += yajin_jine dashou.save(update_fields=['yajin']) + czjilu.zhuangtai = MEMBER_PAID_STATUS + czjilu.save(update_fields=['zhuangtai']) response_data['yajin'] = dashou.yajin except UserDashou.DoesNotExist: - return Response({'code': 500, 'msg': '打手信息不存在,无法充值押金'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + setattr(locked_profile, identity['balance_field'], current_balance) + locked_profile.save(update_fields=[identity['balance_field']]) + czjilu.zhuangtai = MEMBER_FAILED_STATUS + czjilu.shuoming = f"{shuoming} | 履约失败:打手不存在"[:100] + czjilu.save(update_fields=['zhuangtai', 'shuoming']) + fulfill_error = MemberRechargeError('打手信息不存在,无法充值押金', 'dashou_not_found') elif leixing == 4: if fadan_obj.Status not in [1, 3]: @@ -1082,14 +1102,29 @@ class DsqrgmdhView(APIView): new_jifen = dashou.jifen + INTEGRAL_INCREMENT dashou.jifen = min(new_jifen, MAX_INTEGRAL) dashou.save(update_fields=['jifen']) + czjilu.zhuangtai = MEMBER_PAID_STATUS + czjilu.save(update_fields=['zhuangtai']) response_data['jifen'] = dashou.jifen except UserDashou.DoesNotExist: - return Response({'code': 500, 'msg': '打手信息不存在,无法补充积分'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + setattr(locked_profile, identity['balance_field'], current_balance) + locked_profile.save(update_fields=[identity['balance_field']]) + czjilu.zhuangtai = MEMBER_FAILED_STATUS + czjilu.shuoming = f"{shuoming} | 履约失败:打手不存在"[:100] + czjilu.save(update_fields=['zhuangtai', 'shuoming']) + fulfill_error = MemberRechargeError('打手信息不存在,无法补充积分', 'dashou_not_found') except Exception as e: logger.error(f"事务处理异常: {str(e)}", exc_info=True) return Response({'code': 500, 'msg': f'支付处理失败: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + if fulfill_error: + logger.warning('余额抵扣履约失败 dingdan=%s err=%s', dingdan_id, fulfill_error) + return Response({ + 'code': 400, + 'msg': str(fulfill_error), + 'data': response_data, + }, status=status.HTTP_400_BAD_REQUEST) + final_profile = identity['model'].query.get(user=user) response_data['balance'] = str(getattr(final_profile, identity['balance_field'])) diff --git a/products/views/huiyuan_payment.py b/products/views/huiyuan_payment.py index 69a0293..4f56bc0 100644 --- a/products/views/huiyuan_payment.py +++ b/products/views/huiyuan_payment.py @@ -168,8 +168,15 @@ class HuiyuanGoumai(APIView): try: pay_openid, _ = get_payment_openid(request) if not pay_openid: - chongzhi_jilu.delete() - return Response({'code': 400, 'message': '用户openid不存在'}, status=status.HTTP_400_BAD_REQUEST) + from jituan.services.member_recharge import MEMBER_CANCELLED_STATUS + chongzhi_jilu.zhuangtai = MEMBER_CANCELLED_STATUS + chongzhi_jilu.shuoming = f"{shuoming} | 下单失败:openid不存在"[:100] + chongzhi_jilu.save(update_fields=['zhuangtai', 'shuoming']) + return Response({ + 'code': 400, + 'message': '用户openid不存在', + 'dingdanid': dingdanid, + }, status=status.HTTP_400_BAD_REQUEST) pay_params = self.generate_wechat_pay_params( dingdanid=dingdanid, jine=jine, @@ -177,8 +184,10 @@ class HuiyuanGoumai(APIView): pay_type='huiyuan' ) except Exception as e: - # 支付参数生成失败,删除订单记录 - chongzhi_jilu.delete() + from jituan.services.member_recharge import MEMBER_CANCELLED_STATUS + chongzhi_jilu.zhuangtai = MEMBER_CANCELLED_STATUS + chongzhi_jilu.shuoming = f"{shuoming} | 下单失败:{str(e)[:40]}"[:100] + chongzhi_jilu.save(update_fields=['zhuangtai', 'shuoming']) logger.error(f"生成会员支付参数失败: {str(e)}") return Response({ 'code': 500, diff --git a/products/views/product_query.py b/products/views/product_query.py index 6d72083..ecd78fb 100644 --- a/products/views/product_query.py +++ b/products/views/product_query.py @@ -290,26 +290,7 @@ class DashouHuiyuanList(APIView): ) } if _huiyuan_ids else {} - # 批量预取 2:当前用户已购 Huiyuangoumai,按 huiyuan_id 索引 - _goumai_map = { - gm.huiyuan_id: gm - for gm in Huiyuangoumai.query.filter( - yonghu_id=yonghuid, - huiyuan_id__in=_huiyuan_ids, - ) - } if _huiyuan_ids else {} - - # 批量预取 3:当前用户在本俱乐部已支付正式会员的 huiyuan_id 集合 - _formal_paid_set = set( - Czjilu.query.filter( - yonghuid=yonghuid, - huiyuan_id__in=_huiyuan_ids, - leixing=1, - zhuangtai=3, - club_id=club_id, - is_trial=False, - ).values_list('huiyuan_id', flat=True) - ) if _huiyuan_ids else set() + from jituan.services.member_recharge import can_purchase_trial def _local_sellable(cp_row, is_trial): """单行 ClubHuiyuanPrice 计算 sellable/price/days(避免重复查询)。""" @@ -330,17 +311,9 @@ class DashouHuiyuanList(APIView): return True, price, days def _local_can_trial(huiyuan_id): - """基于已预取的数据判断体验会员是否可购。""" - cp_row = _club_price_map.get(huiyuan_id) - trial_sellable, _, _ = _local_sellable(cp_row, is_trial=True) - if not trial_sellable: - return False - goumai = _goumai_map.get(huiyuan_id) - if goumai and goumai.has_used_trial: - return False - if huiyuan_id in _formal_paid_set: - return False - return True + """与下单/履约共用 can_purchase_trial。""" + ok, _ = can_purchase_trial(yonghuid, huiyuan_id, club_id) + return ok huiyuan_list = [] for huiyuan in huiyuan_queryset: