feat: 会员体验版、俱乐部分离上架与分红计数加固

扩展 club_huiyuan_price/huiyuangoumai/czjilu/gsfenhong 字段;微信购会员强制写 czjilu+收支入账;formal_cishu 仅统计正式已支付单;关闭余额购会员;俱乐部上架/下架 API。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-06-26 01:32:33 +08:00
parent deb1688d0c
commit 2f0838e552
11 changed files with 689 additions and 221 deletions

View File

@@ -1,4 +1,4 @@
"""后台会员管理:全局 Huiyuan 定义 + 各俱乐部 ClubHuiyuanPrice 售价/分成。"""
"""后台会员管理:全局 Huiyuan 定义 + 各俱乐部 ClubHuiyuanPrice 售价/分成/体验配置"""
import logging
import random
import string
@@ -14,13 +14,26 @@ from products.models import Huiyuan
logger = logging.getLogger(__name__)
CLUB_WRITE_SCOPE_MSG = '当前为集团汇总视图,请先在顶栏切换到具体俱乐部(如星阙、星之界)后再操作'
CLUB_WRITE_SCOPE_MSG = '当前为集团汇总视图,请先在顶栏切换到具体俱乐部后再操作'
def club_write_blocked(request):
return resolve_club_scope(request) == DATA_SCOPE_ALL
def _price_row_to_dict(row):
if not row:
return {}
return {
'formal_days': int(row.formal_days or 30),
'trial_enabled': bool(row.trial_enabled),
'trial_price': str(row.trial_price or 0),
'trial_days': int(row.trial_days or 0),
'trial_guanshifc': str(row.trial_guanshifc or 0),
'trial_zuzhangfc': str(row.trial_zuzhangfc or 0),
}
def _format_member(m, price_row=None):
global_jiage = str(m.jiage)
global_guanshifc = str(m.guanshifc)
@@ -41,6 +54,12 @@ def _format_member(m, price_row=None):
'bankuai_id': m.bankuai_id,
'club_enabled': True,
'price_source': 'global',
'formal_days': 30,
'trial_enabled': False,
'trial_price': '0.00',
'trial_days': 0,
'trial_guanshifc': '0.00',
'trial_zuzhangfc': '0.00',
}
if price_row is not None:
item['jiage'] = str(price_row.jiage)
@@ -48,6 +67,7 @@ def _format_member(m, price_row=None):
item['zuzhangfc'] = str(price_row.zuzhangfc)
item['club_enabled'] = bool(price_row.is_enabled)
item['price_source'] = 'club'
item.update(_price_row_to_dict(price_row))
return item
@@ -81,20 +101,70 @@ def build_member_list_payload(request):
'club_id': club_id,
'scope': scope,
'price_note': (
f'当前俱乐部 {club_id} 专属售价(club_huiyuan_price'
'若与星阙相同,是创建时从 xq 复制的默认值,请在本俱乐部视图下修改价格。'
f'当前俱乐部 {club_id} 专属售价club_huiyuan_price'
if scope != DATA_SCOPE_ALL else
'集团汇总:展示全局默认价。请切换具体俱乐部查看/编辑专属价'
'集团汇总:展示全局默认价切换具体俱乐部可配置售价与体验会员'
),
**list_response_meta(request),
}
def update_member_for_club(request, huiyuan_id, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao):
def build_member_catalog_payload(request):
"""集团会员类型 + 本俱乐部未上架的可选列表。"""
club_id = resolve_club_id_from_request(request)
enabled_ids = set(
ClubHuiyuanPrice.query.filter(club_id=club_id, is_enabled=True).values_list('huiyuan_id', flat=True)
)
all_members = Huiyuan.query.all().order_by('-CreateTime')
catalog = []
for m in all_members:
if m.huiyuan_id in enabled_ids:
continue
catalog.append({
'huiyuan_id': m.huiyuan_id,
'jieshao': m.jieshao,
'jtjieshao': m.jtjieshao,
})
return {'club_id': club_id, 'catalog': catalog}
def _parse_trial_fields(data, jiage, guanshifc, zuzhangfc):
formal_days = int(data.get('formal_days') or 30)
trial_enabled = bool(data.get('trial_enabled'))
trial_price = Decimal(str(data.get('trial_price') or 0))
trial_days = int(data.get('trial_days') or 0)
trial_guanshifc = Decimal(str(data.get('trial_guanshifc') or 0))
trial_zuzhangfc = Decimal(str(data.get('trial_zuzhangfc') or 0))
if formal_days < 1 or formal_days > 3650:
raise ValueError('正式会员天数无效')
if trial_enabled:
if trial_price <= 0 or trial_days <= 0:
raise ValueError('开启体验会员须配置体验价格与天数')
if trial_guanshifc + trial_zuzhangfc > trial_price:
raise ValueError('体验管事+组长分成不能大于体验价格')
if guanshifc + zuzhangfc > jiage:
raise ValueError('管事分成+组长分成不能大于会员价格')
return {
'formal_days': formal_days,
'trial_enabled': trial_enabled,
'trial_price': trial_price,
'trial_days': trial_days,
'trial_guanshifc': trial_guanshifc,
'trial_zuzhangfc': trial_zuzhangfc,
}
def update_member_for_club(request, huiyuan_id, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, extra=None):
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
club_id = club_id_for_write(request)
extra = extra or {}
try:
trial_fields = _parse_trial_fields(extra, jiage, guanshifc, zuzhangfc)
except ValueError as e:
return None, str(e)
with transaction.atomic():
try:
@@ -115,6 +185,7 @@ def update_member_for_club(request, huiyuan_id, jieshao, jiage, guanshifc, zuzha
'zuzhangfc': zuzhangfc,
'is_enabled': True,
'bankuai_id': member.bankuai_id,
**trial_fields,
},
)
if not created:
@@ -122,6 +193,8 @@ def update_member_for_club(request, huiyuan_id, jieshao, jiage, guanshifc, zuzha
row.guanshifc = guanshifc
row.zuzhangfc = zuzhangfc
row.is_enabled = True
for k, v in trial_fields.items():
setattr(row, k, v)
row.save()
if club_id == CLUB_ID_DEFAULT:
@@ -133,11 +206,66 @@ def update_member_for_club(request, huiyuan_id, jieshao, jiage, guanshifc, zuzha
return {'huiyuan_id': huiyuan_id}, None
def add_member_for_club(request, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, bankuai_id=None):
def enable_member_for_club(request, huiyuan_id, jiage, guanshifc, zuzhangfc, extra=None):
"""俱乐部上架集团会员类型。"""
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
club_id = club_id_for_write(request)
extra = extra or {}
try:
member = Huiyuan.query.get(huiyuan_id=huiyuan_id)
except Huiyuan.DoesNotExist:
return None, '集团会员类型不存在'
try:
jiage = Decimal(str(jiage))
guanshifc = Decimal(str(guanshifc))
zuzhangfc = Decimal(str(zuzhangfc))
if jiage <= 0:
raise ValueError
trial_fields = _parse_trial_fields(extra, jiage, guanshifc, zuzhangfc)
except ValueError as e:
return None, str(e) if str(e) else '金额或体验配置错误'
row, created = ClubHuiyuanPrice.query.get_or_create(
club_id=club_id,
huiyuan_id=huiyuan_id,
defaults={
'jiage': jiage,
'guanshifc': guanshifc,
'zuzhangfc': zuzhangfc,
'is_enabled': True,
'bankuai_id': member.bankuai_id,
**trial_fields,
},
)
if not created:
row.jiage = jiage
row.guanshifc = guanshifc
row.zuzhangfc = zuzhangfc
row.is_enabled = True
for k, v in trial_fields.items():
setattr(row, k, v)
row.save()
return {'huiyuan_id': huiyuan_id}, None
def disable_member_for_club(request, huiyuan_id):
"""俱乐部软下架。"""
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
club_id = club_id_for_write(request)
updated = ClubHuiyuanPrice.objects.filter(
club_id=club_id, huiyuan_id=huiyuan_id, is_enabled=True,
).update(is_enabled=False)
if not updated:
return None, '该会员在本俱乐部未上架或已下架'
return {'huiyuan_id': huiyuan_id}, None
def add_global_member(jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, bankuai_id=None):
"""集团创建全局会员类型(仅集团后台)。"""
def generate_huiyuan_id():
while True:
@@ -160,20 +288,18 @@ def add_member_for_club(request, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao
if bankuai_id is not None:
create_kwargs['bankuai_id'] = bankuai_id
member = Huiyuan.query.create(**create_kwargs)
ClubHuiyuanPrice.query.create(
club_id=club_id,
huiyuan_id=new_id,
jiage=jiage,
guanshifc=guanshifc,
zuzhangfc=zuzhangfc,
is_enabled=True,
bankuai_id=bankuai_id,
)
except IntegrityError as e:
logger.exception('添加会员失败 club_id=%s', club_id)
return None, f'添加失败:数据冲突({e}'
logger.exception('创建全局会员失败')
return None, f'创建失败:{e}'
except Exception as e:
logger.exception('添加会员失败 club_id=%s', club_id)
return None, f'添加失败:{e}'
logger.exception('创建全局会员失败')
return None, f'创建失败:{e}'
return {'huiyuan_id': member.huiyuan_id}, None
# 兼容旧名:俱乐部不应再调用
def add_member_for_club(request, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, bankuai_id=None):
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
return None, '俱乐部不可新建会员类型,请从集团会员 catalog 上架'

View File

@@ -1,12 +1,14 @@
"""
会员微信充值履约(回调 / 前端确认共用,幂等)。
分红策略(定稿):
- 第几次购买 cishu本俱乐部该打手该会员,已支付成功 czjilu 笔数(不含本单)+ 1。
与换管事无关,不看 gsfenhong不看管事被分了几次
- cishu=1duoci_fenhong(club_id+受益人+会员+cishu);无则 club_huiyuan_price 默认分成。
- cishu>=2仅 duoci_fenhong无配置则不分(不读管事/组长用户表二次或永久分红字段)
- 价格与默认分成:仅 club_huiyuan_price + order.club_id不读全局 huiyuan 表
分红策略(定稿 v2
- formal_cishu本俱乐部 + 打手 + 会员czjilu 已支付正式单(is_trial=false)笔数 + 1。
与换管事无关;体验单不计入
- formal_cishu=1duoci_fenhong 或 club 默认正式分成。
- formal_cishu>=2仅 duoci_fenhong无配置则不分。
- 体验单:固定 trial_guanshifc / trial_zuzhangfc不计入 formal_cishu
财务:每笔成功履约必须 czjilu.zhuangtai=3 + 收支入账record_wechat_income_once
"""
from __future__ import annotations
@@ -40,6 +42,9 @@ 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
class MemberRechargeError(Exception):
@@ -48,35 +53,110 @@ class MemberRechargeError(Exception):
self.code = code
def club_huiyuan_sellable(club_id, huiyuan_id):
"""仅 club_huiyuan_price 可售;未配置则不可购买。"""
def get_club_huiyuan_row(club_id, huiyuan_id, require_enabled=False):
club_id = club_id or CLUB_ID_DEFAULT
row = ClubHuiyuanPrice.query.filter(
club_id=club_id, huiyuan_id=huiyuan_id, is_enabled=True,
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 has_formal_member_purchase(yonghuid, huiyuan_id, club_id):
"""是否已有成功支付的正式会员单(用于禁止再买体验)。"""
club_id = club_id or CLUB_ID_DEFAULT
return Czjilu.query.filter(
yonghuid=yonghuid,
huiyuan_id=huiyuan_id,
leixing=MEMBER_LEIXING,
zhuangtai=MEMBER_PAID_STATUS,
club_id=club_id,
is_trial=False,
).exists()
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, '本俱乐部未开启体验会员'
goumai = Huiyuangoumai.query.filter(
yonghu_id=yonghuid, huiyuan_id=huiyuan_id,
).first()
if row and row.jiage and Decimal(str(row.jiage)) > 0:
return True, Decimal(str(row.jiage))
return False, Decimal('0')
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 resolve_member_purchase_cishu(yonghuid, huiyuan_id, current_dingdan_id, club_id):
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
同俱乐部 czjilu 已支付笔数(不含本单)+ 1
打手第几次正式购买该会员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=1,
zhuangtai=3,
leixing=MEMBER_LEIXING,
zhuangtai=MEMBER_PAID_STATUS,
club_id=club_id,
is_trial=False,
).exclude(dingdan_id=current_dingdan_id).count()
return prior_paid + 1
# 兼容旧名
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 _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,
@@ -89,40 +169,102 @@ def _lookup_duoci_fenhong(club_id, huiyuan_id, beneficiary_id, cishu, field):
return None
def calc_guanshi_member_fenhong(
huiyuan_id, guanshi_id, cishu, club_id, default_guanshi_fc,
):
"""cishu>=2 只认 duoci_fenhongcishu=1 可回落 club 默认分成。"""
def calc_guanshi_member_fenhong(huiyuan_id, guanshi_id, cishu, club_id, default_guanshi_fc):
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,
):
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):
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
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
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,
)
return record, is_first_buy_any
def _apply_member_fenhong(order, huiyuan, formal_cishu=None, trial_guanshi_fc=None, trial_zuzhang_fc=None):
if Gsfenhong.query.filter(dingdan_id=order.dingdan_id).exists():
logger.info('会员分红已处理,跳过: %s', order.dingdan_id)
return
@@ -154,25 +296,30 @@ def _apply_member_fenhong(order, huiyuan, purchase_cishu):
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))
is_trial_order = bool(getattr(order, 'is_trial', False))
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')
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,
)
logger.warning('分红总额%s超订单%s组长置0', total, order_amount)
zuzhang_fenhong = Decimal('0')
total = guanshi_fenhong
if total > order_amount:
@@ -181,8 +328,8 @@ def _apply_member_fenhong(order, huiyuan, purchase_cishu):
if guanshi_fenhong <= 0 and zuzhang_fenhong <= 0:
logger.info(
'本次无会员分红 dingdan=%s club=%s cishu=%s',
order.dingdan_id, club_id, cishu,
'本次无会员分红 dingdan=%s club=%s cishu=%s is_trial=%s',
order.dingdan_id, club_id, cishu, is_trial_order,
)
return
@@ -218,66 +365,30 @@ def _apply_member_fenhong(order, huiyuan, purchase_cishu):
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:
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,
)
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,
)
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,
)
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,
'会员分红完成 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_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)
@@ -288,17 +399,12 @@ def _add_first_buy_jifen(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:
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
exists = Huiyuangoumai.query.filter(
yonghu_id=order.yonghuid,
huiyuan_id=order.huiyuan_id,
yonghu_id=order.yonghuid, huiyuan_id=order.huiyuan_id,
).exists()
if exists:
return True
@@ -306,44 +412,40 @@ def repair_member_entitlement_if_paid(dingdan_id):
try:
huiyuan = Huiyuan.query.get(huiyuan_id=order.huiyuan_id)
except Huiyuan.DoesNotExist:
logger.error('补开通会员失败,会员不存在 dingdan=%s huiyuan=%s', dingdan_id, order.huiyuan_id)
logger.error('补开通会员失败 dingdan=%s huiyuan=%s', dingdan_id, order.huiyuan_id)
return False
club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT
days = order.purchase_days or 30
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)
_apply_huiyuan_entitlement(
order.yonghuid, order.huiyuan_id, huiyuan, club_id,
bool(getattr(order, 'is_trial', False)), days,
)
logger.warning('已补开通会员 dingdan=%s', dingdan_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:
if order.leixing != MEMBER_LEIXING:
raise MemberRechargeError('订单类型不是会员充值', 'type_error')
if order.zhuangtai != 9:
if order.zhuangtai != MEMBER_PENDING_STATUS:
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)
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,
@@ -363,62 +465,66 @@ 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))
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)
is_first_purchase_of_member = not Huiyuangoumai.query.filter(
yonghu_id=order.yonghuid,
huiyuan_id=order.huiyuan_id,
).exists()
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,
)
# 必须在改 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,
goumai_record, is_first_buy_any = _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 = 3
order.zhuangtai = MEMBER_PAID_STATUS
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)
_record_member_income(order, club_id)
try:
_apply_member_fenhong(order, huiyuan, purchase_cishu)
_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,
'会员分红失败但会员已开通 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,
'会员充值履约完成 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': 3,
'purchase_cishu': purchase_cishu,
'is_first_purchase_of_member': is_first_purchase_of_member,
'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):
"""微信 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))
@@ -456,19 +562,15 @@ def query_wechat_v2_trade_state(out_trade_no, club_id=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:
if order.leixing != MEMBER_LEIXING:
raise MemberRechargeError('不是会员订单', 'type_error')
if order.zhuangtai == 3:
if order.zhuangtai == MEMBER_PAID_STATUS:
goumai = Huiyuangoumai.query.filter(
yonghu_id=yonghuid, huiyuan_id=order.huiyuan_id,
).first()
@@ -482,12 +584,13 @@ def confirm_member_recharge_paid(dingdan_id, yonghuid):
return {
'fulfilled': False,
'already_done': True,
'zhuangtai': 3,
'zhuangtai': MEMBER_PAID_STATUS,
'daoqi_time': goumai.daoqi_time,
'huiyuan_id': order.huiyuan_id,
'is_trial': goumai.is_trial,
}
if order.zhuangtai != 9:
if order.zhuangtai != MEMBER_PENDING_STATUS:
raise MemberRechargeError(f'订单状态异常: {order.zhuangtai}', 'status_error')
trade_state, _ = query_wechat_v2_trade_state(
@@ -502,3 +605,27 @@ def confirm_member_recharge_paid(dingdan_id, yonghuid):
)
result['fulfilled'] = not result.get('already_done')
return result
def dashou_in_active_trial_period(yonghuid):
"""打手是否处于未过期的体验会员期(限制佣金提现)。"""
now = timezone.now()
return Huiyuangoumai.query.filter(
yonghu_id=yonghuid,
is_trial=True,
huiyuan_zhuangtai=1,
daoqi_time__gt=now,
).exists()
def extend_member_entitlement_admin(yonghuid, huiyuan_id, days, club_id=None):
"""后台赠送正式会员(无 czjilu、无分红"""
try:
huiyuan = Huiyuan.query.get(huiyuan_id=huiyuan_id)
except Huiyuan.DoesNotExist:
return None, '会员类型不存在'
days = max(1, int(days))
record, _ = _apply_huiyuan_entitlement(
yonghuid, huiyuan_id, huiyuan, club_id, is_trial=False, purchase_days=days,
)
return record, None