""" 会员微信充值履约(回调 / 前端确认共用,幂等)。 分红策略(定稿): - 第几次购买 cishu:本俱乐部该打手该会员,已支付成功 czjilu 笔数(不含本单)+ 1。 与换管事无关,不看 gsfenhong,不看管事被分了几次。 - cishu=1:先 duoci_fenhong(club_id+受益人+会员+cishu);无则 club_huiyuan_price 默认分成。 - cishu>=2:仅 duoci_fenhong;无配置则不分(不读管事/组长用户表二次或永久分红字段)。 - 价格与默认分成:仅 club_huiyuan_price + order.club_id,不读全局 huiyuan 表。 """ from __future__ import annotations import hashlib import logging import random import string from datetime import timedelta from decimal import Decimal import defusedxml.ElementTree as ET import requests from django.db import transaction from django.utils import timezone from backend.utils import ( update_guanshi_daily_by_action, update_guanshi_xufei_daily, update_zuzhang_daily_by_action, ) from jituan.constants import CLUB_ID_DEFAULT from jituan.models import ClubHuiyuanPrice from jituan.services.club_config import get_huiyuan_fenchong from jituan.services.club_penalty import resolve_gsfenhong_club_id from jituan.services.club_user import get_user_club_id from jituan.services.wechat_pay import club_id_from_czjilu, get_wechat_v2_config from products.models import Czjilu, DuociFenhong, Gsfenhong, Huiyuan, Huiyuangoumai from users.business_models import User from users.models import UserDashou, UserGuanshi, UserZuzhang logger = logging.getLogger(__name__) MEMBER_FENHONG_LEIXING = 1 class MemberRechargeError(Exception): def __init__(self, message, code='error'): super().__init__(message) self.code = code def club_huiyuan_sellable(club_id, huiyuan_id): """仅 club_huiyuan_price 可售;未配置则不可购买。""" club_id = club_id or CLUB_ID_DEFAULT row = ClubHuiyuanPrice.query.filter( club_id=club_id, huiyuan_id=huiyuan_id, is_enabled=True, ).first() if row and row.jiage and Decimal(str(row.jiage)) > 0: return True, Decimal(str(row.jiage)) return False, Decimal('0') def resolve_member_purchase_cishu(yonghuid, huiyuan_id, current_dingdan_id, club_id): """ 打手第几次购买该会员(= duoci_fenhong.cishu)。 同俱乐部 czjilu 已支付笔数(不含本单)+ 1。 """ club_id = club_id or CLUB_ID_DEFAULT prior_paid = Czjilu.query.filter( yonghuid=yonghuid, huiyuan_id=huiyuan_id, leixing=1, zhuangtai=3, club_id=club_id, ).exclude(dingdan_id=current_dingdan_id).count() return prior_paid + 1 def _lookup_duoci_fenhong(club_id, huiyuan_id, beneficiary_id, cishu, field): """有行则返回金额(含 0);无行返回 None 表示未配置。""" try: row = DuociFenhong.query.get( club_id=club_id or CLUB_ID_DEFAULT, huiyuan=huiyuan_id, yonghuid=beneficiary_id, cishu=cishu, ) return Decimal(str(getattr(row, field) or 0)) except DuociFenhong.DoesNotExist: return None def calc_guanshi_member_fenhong( huiyuan_id, guanshi_id, cishu, club_id, default_guanshi_fc, ): """cishu>=2 只认 duoci_fenhong;cishu=1 可回落 club 默认分成。""" club_id = club_id or CLUB_ID_DEFAULT custom = _lookup_duoci_fenhong(club_id, huiyuan_id, guanshi_id, cishu, 'guanshi_fenhong') if custom is not None: return custom if custom > 0 else None if cishu == 1: base = default_guanshi_fc if default_guanshi_fc is not None else Decimal('0') return base if base > 0 else None return None def calc_zuzhang_member_fenhong( huiyuan_id, zuzhang_id, cishu, club_id, default_zuzhang_fc, ): if not zuzhang_id: return None club_id = club_id or CLUB_ID_DEFAULT custom = _lookup_duoci_fenhong(club_id, huiyuan_id, zuzhang_id, cishu, 'zuzhang_fenhong') if custom is not None: return custom if custom > 0 else None if cishu == 1: base = default_zuzhang_fc if default_zuzhang_fc is not None else Decimal('0') return base if base > 0 else None return None def _apply_member_fenhong(order, huiyuan, purchase_cishu): if Gsfenhong.query.filter(dingdan_id=order.dingdan_id).exists(): logger.info('会员分红已处理,跳过: %s', order.dingdan_id) return try: dashou = UserDashou.query.get(user__UserUID=order.yonghuid) except UserDashou.DoesNotExist: logger.warning('打手不存在,跳过会员分红: %s', order.yonghuid) return guanshi_id = dashou.yaoqingren if not guanshi_id: logger.info('打手%s无邀请管事,跳过会员分红', order.yonghuid) return try: guanshi = UserGuanshi.query.get(user__UserUID=guanshi_id) except UserGuanshi.DoesNotExist: logger.warning('管事不存在: %s', guanshi_id) return zuzhang_id = guanshi.yaoqingren zuzhang = None if zuzhang_id: try: zuzhang = UserZuzhang.query.get(user__UserUID=zuzhang_id) except UserZuzhang.DoesNotExist: logger.warning('组长不存在: %s', zuzhang_id) club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT huiyuan_id = huiyuan.huiyuan_id cishu = purchase_cishu guanshi_fc, zuzhang_fc = get_huiyuan_fenchong(club_id, huiyuan_id) order_amount = Decimal(str(order.jine)) guanshi_fenhong = calc_guanshi_member_fenhong( huiyuan_id, guanshi_id, cishu, club_id, guanshi_fc, ) guanshi_fenhong = guanshi_fenhong if guanshi_fenhong is not None else Decimal('0') zuzhang_fenhong = calc_zuzhang_member_fenhong( huiyuan_id, zuzhang_id, cishu, club_id, zuzhang_fc, ) zuzhang_fenhong = zuzhang_fenhong if zuzhang_fenhong is not None else Decimal('0') total = guanshi_fenhong + zuzhang_fenhong if total > order_amount: logger.warning( '分红总额%s超订单%s,组长置0', total, order_amount, ) zuzhang_fenhong = Decimal('0') total = guanshi_fenhong if total > order_amount: logger.warning('管事分红%s超订单,置0', guanshi_fenhong) guanshi_fenhong = Decimal('0') if guanshi_fenhong <= 0 and zuzhang_fenhong <= 0: logger.info( '本次无会员分红 dingdan=%s club=%s cishu=%s', order.dingdan_id, club_id, cishu, ) return user_main = dashou.user if guanshi_fenhong > 0: guanshi.chongzhifenrun += guanshi_fenhong guanshi.yue += guanshi_fenhong guanshi.jinrichongzhi += 1 guanshi.jinyuechongzhi += 1 guanshi.save(update_fields=[ 'chongzhifenrun', 'yue', 'jinrichongzhi', 'jinyuechongzhi', ]) if zuzhang_fenhong > 0 and zuzhang: zuzhang.fenyong_zonge += zuzhang_fenhong zuzhang.ketixian_jine += zuzhang_fenhong zuzhang.jinri_fenyong += zuzhang_fenhong zuzhang.jinyue_fenyong += zuzhang_fenhong zuzhang.save(update_fields=[ 'fenyong_zonge', 'ketixian_jine', 'jinri_fenyong', 'jinyue_fenyong', ]) Gsfenhong.query.create( dingdan_id=order.dingdan_id, guanshi=guanshi_id, dashouid=order.yonghuid, shuoming=order.shuoming, fenhong=guanshi_fenhong, avatar=user_main.Avatar, nicheng=dashou.nicheng or '未知打手', zuzhang_id=zuzhang_id if zuzhang_fenhong > 0 else None, zuzhang_fenhong=zuzhang_fenhong if zuzhang_fenhong > 0 else None, huiyuan_id=huiyuan_id, fenhong_leixing=MEMBER_FENHONG_LEIXING, club_id=resolve_gsfenhong_club_id(dingdan_id=order.dingdan_id, czjilu=order), ) if guanshi_fenhong > 0: try: if cishu == 1: update_guanshi_daily_by_action( yonghuid=guanshi_id, action=2, amount=guanshi_fenhong, ) else: update_guanshi_xufei_daily( yonghuid=guanshi_id, xufei_jine=guanshi_fenhong, ) except Exception as exc: logger.error('管事日统计更新失败: %s', exc) if zuzhang_fenhong > 0 and zuzhang_id: try: update_zuzhang_daily_by_action( yonghuid=zuzhang_id, action=2, amount=zuzhang_fenhong, ) except Exception as exc: logger.error('组长日统计更新失败: %s', exc) logger.info( '会员分红完成 dingdan=%s club=%s cishu=%s 管事=%s 组长=%s', order.dingdan_id, club_id, cishu, guanshi_fenhong, zuzhang_fenhong, ) def _apply_huiyuan_goumai(yonghuid, huiyuan_id, huiyuan, club_id): """返回 (record, is_first_purchase_of_member, is_first_buy_any_member)。""" existing = Huiyuangoumai.query.filter( yonghu_id=yonghuid, huiyuan_id=huiyuan_id, ).first() is_first_purchase = existing is None is_first_buy_any = False if existing: existing.daoqi_time += timedelta(days=30) existing.huiyuan_zhuangtai = 1 existing.save(update_fields=['daoqi_time', 'huiyuan_zhuangtai', 'UpdateTime']) return existing, False, False other = Huiyuangoumai.query.filter(yonghu_id=yonghuid).exclude( huiyuan_id=huiyuan_id, ).count() if other == 0: is_first_buy_any = True record = Huiyuangoumai.query.create( yonghu_id=yonghuid, huiyuan_id=huiyuan_id, jieshao=huiyuan.jieshao, huiyuan_zhuangtai=1, daoqi_time=timezone.now() + timedelta(days=30), club_id=club_id or get_user_club_id(User.query.filter(UserUID=yonghuid).first()), ) return record, is_first_purchase, is_first_buy_any def _add_first_buy_jifen(yonghuid): try: dashou = UserDashou.query.get(user__UserUID=yonghuid) dashou.jifen += 10 dashou.save(update_fields=['jifen']) except UserDashou.DoesNotExist: logger.warning('首次购会员加积分失败,打手不存在: %s', yonghuid) def repair_member_entitlement_if_paid(dingdan_id): """ 订单已标为已支付(zhuangtai=3)但 huiyuangoumai 缺失时补开通会员。 用于修复历史脏数据或履约中断。 """ order = Czjilu.query.filter(dingdan_id=dingdan_id, leixing=1).first() if not order or order.zhuangtai != 3 or not order.huiyuan_id: return False exists = Huiyuangoumai.query.filter( yonghu_id=order.yonghuid, huiyuan_id=order.huiyuan_id, ).exists() if exists: return True try: huiyuan = Huiyuan.query.get(huiyuan_id=order.huiyuan_id) except Huiyuan.DoesNotExist: logger.error('补开通会员失败,会员不存在 dingdan=%s huiyuan=%s', dingdan_id, order.huiyuan_id) return False club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT with transaction.atomic(): _apply_huiyuan_goumai(order.yonghuid, order.huiyuan_id, huiyuan, club_id) logger.warning('已补开通会员 dingdan=%s yonghu=%s huiyuan=%s', dingdan_id, order.yonghuid, order.huiyuan_id) return True @transaction.atomic def fulfill_member_recharge(dingdan_id, paid_amount_yuan=None, source='callback'): """ 幂等履约:仅当 czjilu.zhuangtai==9 时执行。 已处理 (zhuangtai!=9) 直接返回 already_done=True。 """ locked = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update() order = locked.first() if not order: raise MemberRechargeError('订单不存在', 'not_found') if order.leixing != 1: raise MemberRechargeError('订单类型不是会员充值', 'type_error') if order.zhuangtai != 9: logger.info( '会员订单已处理,跳过履约 dingdan=%s zhuangtai=%s source=%s', dingdan_id, order.zhuangtai, source, ) try: from jituan.services.szjilu_accounting import repair_wechat_income_if_missing repair_wechat_income_if_missing( Decimal(str(order.jine)), getattr(order, 'club_id', None) or CLUB_ID_DEFAULT, biz_ref=f'cz:{dingdan_id}', ) except Exception as exc: logger.error('会员补记收支失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True) return { 'already_done': True, 'dingdan_id': dingdan_id, 'zhuangtai': order.zhuangtai, } if paid_amount_yuan is not None: paid = Decimal(str(paid_amount_yuan)) order_amt = Decimal(str(order.jine)) if abs(paid - order_amt) > Decimal('0.01'): raise MemberRechargeError( f'支付金额{paid}与订单{order_amt}不一致', 'amount_mismatch', ) huiyuan = Huiyuan.query.filter(huiyuan_id=order.huiyuan_id).first() if not huiyuan: raise MemberRechargeError(f'会员不存在: {order.huiyuan_id}', 'huiyuan_not_found') club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT is_first_purchase_of_member = not Huiyuangoumai.query.filter( yonghu_id=order.yonghuid, huiyuan_id=order.huiyuan_id, ).exists() # 必须在改 zhuangtai 之前算清第几次购买 purchase_cishu = resolve_member_purchase_cishu( order.yonghuid, order.huiyuan_id, order.dingdan_id, club_id, ) # 先开通会员,再记账;分红失败不得回滚会员 goumai_record, _, is_first_buy_any = _apply_huiyuan_goumai( order.yonghuid, order.huiyuan_id, huiyuan, club_id, ) if is_first_buy_any: _add_first_buy_jifen(order.yonghuid) order.zhuangtai = 3 order.save(update_fields=['zhuangtai']) huiyuan.goumai_cishu = (huiyuan.goumai_cishu or 0) + 1 huiyuan.save(update_fields=['goumai_cishu']) jine_decimal = Decimal(str(order.jine)) try: from jituan.services.szjilu_accounting import repair_wechat_income_if_missing repair_wechat_income_if_missing(jine_decimal, club_id, biz_ref=f'cz:{dingdan_id}') except Exception as exc: logger.error('会员收支统计更新失败: %s', exc, exc_info=True) try: _apply_member_fenhong(order, huiyuan, purchase_cishu) except Exception as exc: logger.error( '会员分红失败但会员已开通 dingdan=%s err=%s', dingdan_id, exc, exc_info=True, ) logger.info( '会员充值履约完成 dingdan=%s source=%s club=%s purchase_cishu=%s first_member=%s', dingdan_id, source, club_id, purchase_cishu, is_first_purchase_of_member, ) return { 'already_done': False, 'dingdan_id': dingdan_id, 'zhuangtai': 3, 'purchase_cishu': purchase_cishu, 'is_first_purchase_of_member': is_first_purchase_of_member, 'daoqi_time': goumai_record.daoqi_time, 'huiyuan_id': order.huiyuan_id, } def query_wechat_v2_trade_state(out_trade_no, club_id=None): """微信 V2 查单,返回 (trade_state, transaction_id) 或 (None, None)。""" club_id = club_id or club_id_from_czjilu(out_trade_no) cfg = get_wechat_v2_config(club_id) nonce_str = ''.join(random.choices(string.ascii_letters + string.digits, k=32)) params = { 'appid': cfg['appid'], 'mch_id': cfg['mch_id'], 'out_trade_no': out_trade_no, 'nonce_str': nonce_str, } string_a = '&'.join(f'{k}={params[k]}' for k in sorted(params.keys())) sign = hashlib.md5(f'{string_a}&key={cfg["key"]}'.encode()).hexdigest().upper() params['sign'] = sign xml_body = ''.join(f'<{k}>{params[k]}' for k in params) xml_data = f'{xml_body}' try: resp = requests.post( 'https://api.mch.weixin.qq.com/pay/orderquery', data=xml_data.encode('utf-8'), headers={'Content-Type': 'application/xml'}, timeout=10, ) root = ET.fromstring(resp.content) if root.find('return_code').text != 'SUCCESS': return None, None if root.find('result_code').text != 'SUCCESS': return None, None trade_state = root.find('trade_state').text txn = root.find('transaction_id') transaction_id = txn.text if txn is not None else None return trade_state, transaction_id except Exception as exc: logger.error('微信查单失败 %s: %s', out_trade_no, exc) return None, None def confirm_member_recharge_paid(dingdan_id, yonghuid): """ 前端支付成功确认:先查微信 SUCCESS 再幂等履约。 已履约则直接返回成功,不与回调重复入账。 """ try: order = Czjilu.query.get(dingdan_id=dingdan_id, yonghuid=yonghuid) except Czjilu.DoesNotExist: raise MemberRechargeError('订单不存在或不属于当前用户', 'not_found') if order.leixing != 1: raise MemberRechargeError('不是会员订单', 'type_error') if order.zhuangtai == 3: goumai = Huiyuangoumai.query.filter( yonghu_id=yonghuid, huiyuan_id=order.huiyuan_id, ).first() if not goumai: repair_member_entitlement_if_paid(dingdan_id) goumai = Huiyuangoumai.query.filter( yonghu_id=yonghuid, huiyuan_id=order.huiyuan_id, ).first() if not goumai: raise MemberRechargeError('订单已付但会员记录缺失,请联系客服', 'data_error') return { 'fulfilled': False, 'already_done': True, 'zhuangtai': 3, 'daoqi_time': goumai.daoqi_time, 'huiyuan_id': order.huiyuan_id, } if order.zhuangtai != 9: raise MemberRechargeError(f'订单状态异常: {order.zhuangtai}', 'status_error') trade_state, _ = query_wechat_v2_trade_state( dingdan_id, getattr(order, 'club_id', None), ) if trade_state != 'SUCCESS': raise MemberRechargeError('微信侧尚未支付成功,请稍后再试', 'not_paid') total_fee_yuan = Decimal(str(order.jine)) result = fulfill_member_recharge( dingdan_id, paid_amount_yuan=total_fee_yuan, source='confirm', ) result['fulfilled'] = not result.get('already_done') return result