496 lines
18 KiB
Python
496 lines
18 KiB
Python
"""
|
||
会员微信充值履约(回调 / 前端确认共用,幂等)。
|
||
|
||
分红策略(定稿):
|
||
- 第几次购买 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,
|
||
)
|
||
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 record_wechat_income_once
|
||
record_wechat_income_once(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]}</{k}>' for k in params)
|
||
xml_data = f'<xml>{xml_body}</xml>'
|
||
|
||
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
|