1198 lines
45 KiB
Python
1198 lines
45 KiB
Python
"""
|
||
会员微信充值履约(回调 / 前端确认共用,幂等)。
|
||
|
||
分红策略(定稿 v2):
|
||
- formal_cishu:本俱乐部 + 打手 + 会员,已支付正式单笔数 + 1(体验单及历史脏数据不计入)。
|
||
- formal_cishu=1:duoci_fenhong 或 club 默认正式分成。
|
||
- formal_cishu>=2:仅 duoci_fenhong;无配置则不分。
|
||
- 体验单:固定 trial_guanshifc / trial_zuzhangfc;终身仅可购 1 次。
|
||
- 积分:体验 +5,正式 +5(体验后)或直接至 10(未买过体验),封顶 10。
|
||
- 履约(回调/轮询/余额)均二次校验体验资格;分红失败回滚事务。
|
||
|
||
财务:每笔成功履约必须 czjilu.zhuangtai=3 + 收支入账(record_wechat_income_once)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import logging
|
||
import random
|
||
import string
|
||
from datetime import datetime, 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
|
||
MEMBER_LEIXING = 1
|
||
MEMBER_PAID_STATUS = 3
|
||
MEMBER_PENDING_STATUS = 9
|
||
MEMBER_FAILED_STATUS = 8
|
||
MEMBER_CANCELLED_STATUS = 2
|
||
ENTITLEMENT_REVOKED_MARKER = '权益已撤销'
|
||
MEMBER_JIFEN_CAP = 10
|
||
MEMBER_JIFEN_TRIAL_GRANT = 5
|
||
MEMBER_JIFEN_FORMAL_GRANT = 5
|
||
|
||
|
||
class MemberRechargeError(Exception):
|
||
def __init__(self, message, code='error'):
|
||
super().__init__(message)
|
||
self.code = code
|
||
|
||
|
||
def get_club_huiyuan_row(club_id, huiyuan_id, require_enabled=False):
|
||
club_id = club_id or CLUB_ID_DEFAULT
|
||
qs = ClubHuiyuanPrice.query.filter(club_id=club_id, huiyuan_id=huiyuan_id)
|
||
if require_enabled:
|
||
qs = qs.filter(is_enabled=True)
|
||
return qs.first()
|
||
|
||
|
||
def club_huiyuan_sellable(club_id, huiyuan_id, is_trial=False):
|
||
"""正式/体验是否可售及价格、天数。"""
|
||
club_id = club_id or CLUB_ID_DEFAULT
|
||
row = get_club_huiyuan_row(club_id, huiyuan_id, require_enabled=True)
|
||
if not row:
|
||
return False, Decimal('0'), 0
|
||
|
||
if is_trial:
|
||
if not row.trial_enabled:
|
||
return False, Decimal('0'), 0
|
||
price = Decimal(str(row.trial_price or 0))
|
||
days = int(row.trial_days or 0)
|
||
if price <= 0 or days <= 0:
|
||
return False, Decimal('0'), 0
|
||
return True, price, days
|
||
|
||
price = Decimal(str(row.jiage or 0))
|
||
days = int(row.formal_days or 30)
|
||
if price <= 0:
|
||
return False, Decimal('0'), 0
|
||
return True, price, days
|
||
|
||
|
||
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
|
||
qs = Czjilu.query.filter(
|
||
yonghuid=yonghuid,
|
||
huiyuan_id=huiyuan_id,
|
||
leixing=MEMBER_LEIXING,
|
||
zhuangtai=MEMBER_PAID_STATUS,
|
||
club_id=club_id,
|
||
)
|
||
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):
|
||
"""体验会员是否允许购买。"""
|
||
club_id = club_id or CLUB_ID_DEFAULT
|
||
sellable, _, _ = club_huiyuan_sellable(club_id, huiyuan_id, is_trial=True)
|
||
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()
|
||
if goumai and goumai.has_used_trial:
|
||
return False, '体验会员仅可购买一次'
|
||
|
||
if has_formal_member_purchase(yonghuid, huiyuan_id, club_id):
|
||
return False, '已购买过正式会员,不可再购买体验版'
|
||
|
||
return True, None
|
||
|
||
|
||
def validate_member_purchase(yonghuid, huiyuan_id, club_id, is_trial):
|
||
"""下单前校验。"""
|
||
is_trial = bool(is_trial)
|
||
if is_trial:
|
||
ok, msg = can_purchase_trial(yonghuid, huiyuan_id, club_id)
|
||
if not ok:
|
||
return False, msg, Decimal('0'), 0
|
||
sellable, price, days = club_huiyuan_sellable(club_id, huiyuan_id, is_trial=True)
|
||
else:
|
||
sellable, price, days = club_huiyuan_sellable(club_id, huiyuan_id, is_trial=False)
|
||
if not sellable:
|
||
return False, '该俱乐部未配置此会员或已下架', Decimal('0'), 0
|
||
|
||
if not sellable:
|
||
return False, '会员不可购买', Decimal('0'), 0
|
||
return True, None, price, days
|
||
|
||
|
||
def resolve_formal_purchase_cishu(yonghuid, huiyuan_id, current_dingdan_id, club_id):
|
||
"""
|
||
打手第几次正式购买该会员(duoci_fenhong.cishu)。
|
||
体验单(含历史脏数据)不计入。
|
||
"""
|
||
club_id = club_id or CLUB_ID_DEFAULT
|
||
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)
|
||
|
||
|
||
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))
|
||
if is_trial:
|
||
raise MemberRechargeError(
|
||
'体验会员仅支持微信支付,不可用佣金、分红或押金抵扣',
|
||
'trial_balance_forbidden',
|
||
)
|
||
|
||
ok, err_msg, _, _ = validate_member_purchase(
|
||
czjilu.yonghuid, czjilu.huiyuan_id, club_id, is_trial=False,
|
||
)
|
||
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.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'])
|
||
|
||
_apply_member_fenhong(
|
||
czjilu, huiyuan,
|
||
formal_cishu=formal_cishu,
|
||
trial_guanshi_fc=trial_guanshi_fc,
|
||
trial_zuzhangfc=trial_zuzhangfc,
|
||
)
|
||
|
||
return goumai_record, new_jifen
|
||
|
||
|
||
def _lookup_duoci_fenhong(club_id, huiyuan_id, beneficiary_id, cishu, field):
|
||
"""返回 (金额, 是否已配置行)。已配置为 0 时不应再回落到永久分红。"""
|
||
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)), True
|
||
except DuociFenhong.DoesNotExist:
|
||
return None, False
|
||
|
||
|
||
def calc_guanshi_member_fenhong(huiyuan_id, guanshi_id, cishu, club_id, default_guanshi_fc):
|
||
club_id = club_id or CLUB_ID_DEFAULT
|
||
custom, configured = _lookup_duoci_fenhong(
|
||
club_id, huiyuan_id, guanshi_id, cishu, 'guanshi_fenhong',
|
||
)
|
||
if configured:
|
||
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
|
||
try:
|
||
guanshi = UserGuanshi.query.get(user__UserUID=guanshi_id)
|
||
if guanshi.fenghong_erci_enabled:
|
||
permanent = Decimal(str(guanshi.fenghong_erci or 0))
|
||
if permanent > 0:
|
||
return permanent
|
||
except UserGuanshi.DoesNotExist:
|
||
pass
|
||
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, configured = _lookup_duoci_fenhong(
|
||
club_id, huiyuan_id, zuzhang_id, cishu, 'zuzhang_fenhong',
|
||
)
|
||
if configured:
|
||
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
|
||
try:
|
||
zuzhang = UserZuzhang.query.get(user__UserUID=zuzhang_id)
|
||
if zuzhang.kaioi_ewai_fenhong:
|
||
permanent = Decimal(str(zuzhang.ewai_fenhong_jine or 0))
|
||
if permanent > 0:
|
||
return permanent
|
||
except UserZuzhang.DoesNotExist:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _calc_new_daoqi(existing, days):
|
||
"""未过期叠加;已过期从今天算。"""
|
||
now = timezone.now()
|
||
days = max(1, int(days))
|
||
if existing and existing.daoqi_time and existing.daoqi_time > now and existing.huiyuan_zhuangtai == 1:
|
||
return existing.daoqi_time + timedelta(days=days)
|
||
return now + timedelta(days=days)
|
||
|
||
|
||
def _record_member_income(order, club_id):
|
||
"""会员充值收入入账(幂等 biz_ref=cz:dingdan_id)。"""
|
||
jine_decimal = Decimal(str(order.jine))
|
||
try:
|
||
from jituan.services.szjilu_accounting import record_wechat_income_once
|
||
ok = record_wechat_income_once(jine_decimal, club_id, biz_ref=f'cz:{order.dingdan_id}')
|
||
if not ok:
|
||
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
||
repair_wechat_income_if_missing(jine_decimal, club_id, biz_ref=f'cz:{order.dingdan_id}')
|
||
logger.info(
|
||
'会员收入入账 dingdan=%s club=%s jine=%s is_trial=%s',
|
||
order.dingdan_id, club_id, jine_decimal, getattr(order, 'is_trial', False),
|
||
)
|
||
except Exception as exc:
|
||
logger.error(
|
||
'会员收入入账失败 dingdan=%s club=%s jine=%s err=%s',
|
||
order.dingdan_id, club_id, order.jine, exc, exc_info=True,
|
||
)
|
||
raise MemberRechargeError('收入统计失败,请联系客服', 'income_error')
|
||
|
||
|
||
def _apply_huiyuan_entitlement(yonghuid, huiyuan_id, huiyuan, club_id, is_trial, purchase_days):
|
||
"""开通/续期会员权益。"""
|
||
is_trial = bool(is_trial)
|
||
purchase_days = max(1, int(purchase_days))
|
||
existing = Huiyuangoumai.query.filter(
|
||
yonghu_id=yonghuid, huiyuan_id=huiyuan_id,
|
||
).first()
|
||
is_first_buy_any = False
|
||
new_daoqi = _calc_new_daoqi(existing, purchase_days)
|
||
|
||
if existing:
|
||
existing.daoqi_time = new_daoqi
|
||
existing.huiyuan_zhuangtai = 1
|
||
existing.jieshao = huiyuan.jieshao
|
||
if is_trial:
|
||
existing.is_trial = True
|
||
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',
|
||
])
|
||
record = existing
|
||
else:
|
||
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,
|
||
huiyuan_id=huiyuan_id,
|
||
jieshao=huiyuan.jieshao,
|
||
huiyuan_zhuangtai=1,
|
||
daoqi_time=new_daoqi,
|
||
club_id=resolved_club,
|
||
is_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
|
||
|
||
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
|
||
order_amount = Decimal(str(order.jine))
|
||
is_trial_order = _order_is_trial_like(order, club_id)
|
||
|
||
if is_trial_order:
|
||
guanshi_fenhong = Decimal(str(trial_guanshi_fc or 0))
|
||
zuzhang_fenhong = Decimal(str(trial_zuzhang_fc or 0)) if zuzhang_id else Decimal('0')
|
||
cishu = None
|
||
else:
|
||
cishu = formal_cishu or resolve_formal_purchase_cishu(
|
||
order.yonghuid, huiyuan_id, order.dingdan_id, club_id,
|
||
)
|
||
guanshi_fc, zuzhang_fc = get_huiyuan_fenchong(club_id, huiyuan_id)
|
||
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 is_trial=%s',
|
||
order.dingdan_id, club_id, cishu, is_trial_order,
|
||
)
|
||
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),
|
||
is_trial=is_trial_order,
|
||
)
|
||
|
||
if guanshi_fenhong > 0 and not is_trial_order and cishu is not None:
|
||
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 formal_cishu=%s is_trial=%s 管事=%s 组长=%s',
|
||
order.dingdan_id, club_id, cishu, is_trial_order, guanshi_fenhong, zuzhang_fenhong,
|
||
)
|
||
|
||
|
||
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)
|
||
except UserDashou.DoesNotExist:
|
||
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 _czjilu_entitlement_revoked(order):
|
||
return ENTITLEMENT_REVOKED_MARKER in (getattr(order, 'shuoming', None) or '')
|
||
|
||
|
||
def _czjilu_entitlement_end(order, club_id=None):
|
||
"""充值单对应权益的原定到期时刻(CreateTime + purchase_days)。"""
|
||
from jituan.services.huiyuan_bundle import _normalize_datetime_for_compare
|
||
|
||
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)
|
||
days = max(int(days or 0), 1)
|
||
start = _normalize_datetime_for_compare(order.CreateTime or timezone.now())
|
||
return start + timedelta(days=days)
|
||
|
||
|
||
def czjilu_entitlement_active(order, club_id=None):
|
||
"""充值单权益是否仍在原定期限内(未撤销且未过期)。"""
|
||
if not order or _czjilu_entitlement_revoked(order):
|
||
return False
|
||
from jituan.services.huiyuan_bundle import _normalize_datetime_for_compare
|
||
now = _normalize_datetime_for_compare(timezone.now())
|
||
return _czjilu_entitlement_end(order, club_id) > now
|
||
|
||
|
||
def user_has_active_czjilu_entitlement(yonghu_id, huiyuan_id, club_id=None):
|
||
"""用户是否仍有未过期的已付充值单支撑该会员类型。"""
|
||
from jituan.services.huiyuan_bundle import huiyuan_ids_match
|
||
|
||
for order in Czjilu.query.filter(
|
||
yonghuid=yonghu_id,
|
||
leixing=MEMBER_LEIXING,
|
||
zhuangtai=MEMBER_PAID_STATUS,
|
||
).order_by('-CreateTime'):
|
||
if not huiyuan_ids_match(order.huiyuan_id, huiyuan_id):
|
||
continue
|
||
if czjilu_entitlement_active(order, club_id):
|
||
return True
|
||
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 _parse_since_option(since):
|
||
"""管理命令 --since 字符串 → 可比较的 datetime。"""
|
||
from django.utils.dateparse import parse_date, parse_datetime
|
||
from jituan.services.huiyuan_bundle import _normalize_datetime_for_compare
|
||
|
||
if not since:
|
||
return None
|
||
if not isinstance(since, str):
|
||
return _normalize_datetime_for_compare(since)
|
||
dt = parse_datetime(since)
|
||
if dt is None:
|
||
d = parse_date(since)
|
||
if d is None:
|
||
return None
|
||
dt = datetime.combine(d, datetime.min.time())
|
||
return _normalize_datetime_for_compare(dt)
|
||
|
||
|
||
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)
|
||
since_dt = _parse_since_option(since)
|
||
if since_dt and updated < since_dt:
|
||
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
|
||
|
||
now = _normalize_datetime_for_compare(timezone.now())
|
||
rec.daoqi_time = now - timedelta(seconds=1)
|
||
rec.huiyuan_zhuangtai = 0
|
||
rec.save(update_fields=['daoqi_time', 'huiyuan_zhuangtai', 'UpdateTime'])
|
||
logger.warning(
|
||
'会员权益已收回 yonghu=%s huiyuan=%s reason=%s',
|
||
rec.yonghu_id, rec.huiyuan_id, reason or 'cleanup',
|
||
)
|
||
return True
|
||
|
||
|
||
def repair_member_entitlement_if_paid(dingdan_id):
|
||
order = Czjilu.query.filter(dingdan_id=dingdan_id, leixing=MEMBER_LEIXING).first()
|
||
if not order or order.zhuangtai != MEMBER_PAID_STATUS or not order.huiyuan_id:
|
||
return False
|
||
if _czjilu_entitlement_revoked(order):
|
||
return False
|
||
|
||
from jituan.services.huiyuan_bundle import huiyuan_ids_match
|
||
for rec in Huiyuangoumai.query.filter(yonghu_id=order.yonghuid):
|
||
if huiyuan_ids_match(rec.huiyuan_id, order.huiyuan_id):
|
||
if not rec.jiance_shifou_daoqi() and rec.huiyuan_zhuangtai == 1:
|
||
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
|
||
is_trial = _order_is_trial_like(order, club_id)
|
||
days = int(getattr(order, 'purchase_days', None) or 0)
|
||
if days <= 0:
|
||
_, _, days = club_huiyuan_sellable(club_id, order.huiyuan_id, is_trial=is_trial)
|
||
days = max(int(days or 0), 1)
|
||
if not czjilu_entitlement_active(order, club_id):
|
||
return False
|
||
with transaction.atomic():
|
||
_apply_huiyuan_entitlement(
|
||
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
|
||
|
||
|
||
def sync_member_entitlements_for_user(yonghu_id, limit=15):
|
||
"""支付回调偶发失败时补权益;禁止在 dddhq 每次请求全量扫单(会覆盖后台移除)。"""
|
||
return repair_recent_paid_entitlements_if_missing(yonghu_id, limit=limit)
|
||
|
||
|
||
def repair_recent_paid_entitlements_if_missing(yonghu_id, limit=3):
|
||
"""仅当用户当前无任何有效会员时,尝试补最近几笔已付单。"""
|
||
from jituan.services.huiyuan_bundle import _membership_record_active
|
||
|
||
if not yonghu_id:
|
||
return 0
|
||
has_active = any(
|
||
_membership_record_active(rec)
|
||
for rec in Huiyuangoumai.query.filter(yonghu_id=yonghu_id)
|
||
)
|
||
if has_active:
|
||
return 0
|
||
|
||
orders = Czjilu.query.filter(
|
||
yonghuid=yonghu_id,
|
||
leixing=MEMBER_LEIXING,
|
||
zhuangtai=MEMBER_PAID_STATUS,
|
||
).order_by('-CreateTime')[: max(1, int(limit or 3))]
|
||
fixed = 0
|
||
for order in orders:
|
||
if _czjilu_entitlement_revoked(order):
|
||
continue
|
||
try:
|
||
if repair_member_entitlement_if_paid(order.dingdan_id):
|
||
fixed += 1
|
||
except Exception as exc:
|
||
logger.error('补会员权益失败 dingdan=%s err=%s', order.dingdan_id, exc)
|
||
return fixed
|
||
|
||
|
||
def revoke_member_entitlement_admin(yonghu_id, huiyuan_id, club_id=None):
|
||
"""后台移除会员:删 huiyuangoumai + 标记充值单已撤销,防止被 sync 补回。"""
|
||
from jituan.services.huiyuan_bundle import huiyuan_ids_match, normalize_huiyuan_id
|
||
|
||
hid = normalize_huiyuan_id(huiyuan_id)
|
||
if not hid:
|
||
return 0, '缺少会员ID'
|
||
|
||
removed = 0
|
||
for rec in list(Huiyuangoumai.query.filter(yonghu_id=yonghu_id)):
|
||
if huiyuan_ids_match(rec.huiyuan_id, hid):
|
||
rec.delete()
|
||
removed += 1
|
||
|
||
for order in Czjilu.query.filter(
|
||
yonghuid=yonghu_id,
|
||
leixing=MEMBER_LEIXING,
|
||
zhuangtai=MEMBER_PAID_STATUS,
|
||
):
|
||
if not huiyuan_ids_match(order.huiyuan_id, hid):
|
||
continue
|
||
if _czjilu_entitlement_revoked(order):
|
||
continue
|
||
note = (order.shuoming or '').strip()
|
||
order.shuoming = _truncate_shuoming(
|
||
f'{note} |{ENTITLEMENT_REVOKED_MARKER}|' if note else ENTITLEMENT_REVOKED_MARKER,
|
||
)
|
||
order.save(update_fields=['shuoming'])
|
||
|
||
if removed == 0:
|
||
return 0, '该打手未购买此会员'
|
||
return removed, None
|
||
|
||
|
||
@transaction.atomic
|
||
def fulfill_member_recharge(dingdan_id, paid_amount_yuan=None, source='callback'):
|
||
locked = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update()
|
||
order = locked.first()
|
||
if not order:
|
||
raise MemberRechargeError('订单不存在', 'not_found')
|
||
|
||
if order.leixing != MEMBER_LEIXING:
|
||
raise MemberRechargeError('订单类型不是会员充值', 'type_error')
|
||
|
||
if order.zhuangtai != MEMBER_PENDING_STATUS:
|
||
logger.info(
|
||
'会员订单已处理,跳过履约 dingdan=%s zhuangtai=%s source=%s',
|
||
dingdan_id, order.zhuangtai, source,
|
||
)
|
||
try:
|
||
club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT
|
||
_record_member_income(order, club_id)
|
||
except MemberRechargeError:
|
||
pass
|
||
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
|
||
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)
|
||
|
||
formal_cishu = None
|
||
trial_guanshi_fc = Decimal('0')
|
||
trial_zuzhang_fc = Decimal('0')
|
||
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,
|
||
)
|
||
|
||
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,
|
||
)
|
||
_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)
|
||
|
||
_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',
|
||
dingdan_id, source, club_id, is_trial, formal_cishu, order.jine,
|
||
)
|
||
return {
|
||
'already_done': False,
|
||
'dingdan_id': dingdan_id,
|
||
'zhuangtai': MEMBER_PAID_STATUS,
|
||
'formal_cishu': formal_cishu,
|
||
'is_trial': is_trial,
|
||
'daoqi_time': goumai_record.daoqi_time,
|
||
'huiyuan_id': order.huiyuan_id,
|
||
}
|
||
|
||
|
||
def query_wechat_v2_trade_state(out_trade_no, club_id=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):
|
||
try:
|
||
order = Czjilu.query.get(dingdan_id=dingdan_id, yonghuid=yonghuid)
|
||
except Czjilu.DoesNotExist:
|
||
raise MemberRechargeError('订单不存在或不属于当前用户', 'not_found')
|
||
|
||
if order.leixing != MEMBER_LEIXING:
|
||
raise MemberRechargeError('不是会员订单', 'type_error')
|
||
|
||
if order.zhuangtai == MEMBER_PAID_STATUS:
|
||
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': MEMBER_PAID_STATUS,
|
||
'daoqi_time': goumai.daoqi_time,
|
||
'huiyuan_id': order.huiyuan_id,
|
||
'is_trial': goumai.is_trial,
|
||
}
|
||
|
||
if order.zhuangtai != MEMBER_PENDING_STATUS:
|
||
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
|
||
|
||
|
||
def dashou_in_active_trial_period(yonghuid):
|
||
"""打手是否处于未过期的体验会员期(禁止佣金提现;与抢单池展示逻辑无关)。"""
|
||
from jituan.services.huiyuan_bundle import huiyuan_ids_match
|
||
|
||
now = timezone.now()
|
||
for rec in Huiyuangoumai.query.filter(yonghu_id=yonghuid):
|
||
if rec.jiance_shifou_daoqi():
|
||
continue
|
||
if rec.huiyuan_zhuangtai != 1:
|
||
continue
|
||
if rec.is_trial:
|
||
return True
|
||
|
||
for order in Czjilu.query.filter(
|
||
yonghuid=yonghuid,
|
||
leixing=MEMBER_LEIXING,
|
||
zhuangtai=MEMBER_PAID_STATUS,
|
||
).order_by('-CreateTime')[:30]:
|
||
cid = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT
|
||
if not _order_is_trial_like(order, cid):
|
||
continue
|
||
hid = order.huiyuan_id
|
||
if not hid:
|
||
continue
|
||
has_formal = False
|
||
for rec in Huiyuangoumai.query.filter(yonghu_id=yonghuid):
|
||
if rec.is_trial:
|
||
continue
|
||
if (
|
||
huiyuan_ids_match(rec.huiyuan_id, hid)
|
||
and not rec.jiance_shifou_daoqi()
|
||
and rec.huiyuan_zhuangtai == 1
|
||
):
|
||
has_formal = True
|
||
break
|
||
if has_formal:
|
||
continue
|
||
days = int(getattr(order, 'purchase_days', None) or 0)
|
||
if days <= 0:
|
||
_, _, days = club_huiyuan_sellable(cid, hid, is_trial=True)
|
||
days = max(days, 1)
|
||
start = order.CreateTime or now
|
||
if timezone.is_naive(start):
|
||
start = timezone.make_aware(start, timezone.get_current_timezone())
|
||
if start + timedelta(days=days) > now:
|
||
return True
|
||
return False
|
||
|
||
|
||
def dashou_has_active_formal_huiyuan(yonghuid):
|
||
"""打手是否持有未过期的正式会员(非体验)。"""
|
||
for rec in Huiyuangoumai.query.filter(yonghu_id=yonghuid):
|
||
if rec.is_trial:
|
||
continue
|
||
if not rec.jiance_shifou_daoqi() and rec.huiyuan_zhuangtai == 1:
|
||
return True
|
||
return False
|
||
|
||
|
||
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
|