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

@@ -1276,28 +1276,20 @@ class KefuUpdateDashouView(APIView):
return Response({'code': 403, 'msg': '无权限给打手加会员'}) return Response({'code': 403, 'msg': '无权限给打手加会员'})
if not huiyuan_id: if not huiyuan_id:
return Response({'code': 400, 'msg': '缺少会员ID'}) return Response({'code': 400, 'msg': '缺少会员ID'})
days = int(value) if value else 30
try: try:
huiyuan = Huiyuan.query.get(huiyuan_id=huiyuan_id) huiyuan = Huiyuan.query.get(huiyuan_id=huiyuan_id)
except Huiyuan.DoesNotExist: except Huiyuan.DoesNotExist:
return Response({'code': 404, 'msg': '会员类型不存在'}) return Response({'code': 404, 'msg': '会员类型不存在'})
from jituan.services.club_user import get_user_club_id from jituan.services.club_user import get_user_club_id
from jituan.services.member_recharge import extend_member_entitlement_admin, get_club_huiyuan_row
player_club = get_user_club_id(dashou_user) player_club = get_user_club_id(dashou_user)
record, created = Huiyuangoumai.objects.select_for_update().get_or_create( row = get_club_huiyuan_row(player_club, huiyuan_id, require_enabled=False)
yonghu_id=dashou_id, days = int(value) if value else int(row.formal_days or 30) if row else 30
huiyuan_id=huiyuan_id, record, err = extend_member_entitlement_admin(
defaults={ dashou_id, huiyuan_id, days, player_club,
'huiyuan_zhuangtai': 1,
'jieshao': huiyuan.jieshao,
'daoqi_time': timezone.now() + timedelta(days=days),
'club_id': player_club,
}
) )
if not created: if err:
new_daoqi = record.daoqi_time + timedelta(days=days) return Response({'code': 400, 'msg': err})
record.daoqi_time = new_daoqi
record.huiyuan_zhuangtai = 1
record.save()
write_xiugai_log( write_xiugai_log(
yonghuid=dashou_id, yonghuid=dashou_id,
xiugaiid=kefu.user.Phone, xiugaiid=kefu.user.Phone,
@@ -1305,9 +1297,9 @@ class KefuUpdateDashouView(APIView):
huiyuants=days, huiyuants=days,
huiyuan_id=huiyuan_id, huiyuan_id=huiyuan_id,
qitashuoming=( qitashuoming=(
f'【后台】给打手添加会员会员ID={huiyuan_id}{huiyuan.jieshao}' f'【后台】给打手添加正式会员会员ID={huiyuan_id}{huiyuan.jieshao}'
f'增加{days}天,到期时间={record.daoqi_time.strftime("%Y-%m-%d %H:%M:%S")}' f'增加{days}天,到期时间={record.daoqi_time.strftime("%Y-%m-%d %H:%M:%S")}'
f'打手ID={dashou_id},操作人={kefu.user.Phone}' f'打手ID={dashou_id},操作人={kefu.user.Phone}(无支付单、无分红)'
), ),
) )
return Response({'code': 0, 'msg': '加会员成功', 'data': {'new_daoqi': record.daoqi_time.strftime('%Y-%m-%d %H:%M:%S')}}) return Response({'code': 0, 'msg': '加会员成功', 'data': {'new_daoqi': record.daoqi_time.strftime('%Y-%m-%d %H:%M:%S')}})
@@ -2827,8 +2819,16 @@ class UpdateMemberView(APIView):
from jituan.services.club_member_admin import update_member_for_club from jituan.services.club_member_admin import update_member_for_club
extra = {
'formal_days': request.data.get('formal_days'),
'trial_enabled': request.data.get('trial_enabled'),
'trial_price': request.data.get('trial_price'),
'trial_days': request.data.get('trial_days'),
'trial_guanshifc': request.data.get('trial_guanshifc'),
'trial_zuzhangfc': request.data.get('trial_zuzhangfc'),
}
data, err = update_member_for_club( data, err = update_member_for_club(
request, huiyuan_id, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, request, huiyuan_id, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, extra=extra,
) )
if err: if err:
return Response({'code': 400, 'msg': err}) return Response({'code': 400, 'msg': err})
@@ -2880,11 +2880,17 @@ class AddMemberView(APIView):
if '3300a' not in permissions: if '3300a' not in permissions:
return Response({'code': 403, 'msg': '无权限添加会员'}) return Response({'code': 403, 'msg': '无权限添加会员'})
from jituan.services.club_member_admin import add_member_for_club from jituan.services.club_member_admin import add_global_member
from jituan.services.club_context import resolve_club_scope
from jituan.constants import DATA_SCOPE_ALL
data, err = add_member_for_club( if resolve_club_scope(request) != DATA_SCOPE_ALL and '000001' not in permissions:
request, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, return Response({
) 'code': 403,
'msg': '仅集团汇总视图可新建全局会员类型;俱乐部请从会员 catalog 上架',
})
data, err = add_global_member(jieshao, jiage, guanshifc, zuzhangfc, jtjieshao)
if err: if err:
return Response({'code': 400, 'msg': err}) return Response({'code': 400, 'msg': err})
return Response({'code': 0, 'msg': '添加成功', 'data': data}) return Response({'code': 0, 'msg': '添加成功', 'data': data})
@@ -2892,6 +2898,78 @@ class AddMemberView(APIView):
class ClubMemberCatalogView(APIView):
"""俱乐部可上架的集团会员 catalog。POST /houtai/hthycatalog"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = request.data.get('username', '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少username'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '3300a' not in permissions:
return Response({'code': 403, 'msg': '无权限'})
from jituan.services.club_member_admin import build_member_catalog_payload
return Response({'code': 0, 'data': build_member_catalog_payload(request)})
class ClubMemberEnableView(APIView):
"""俱乐部上架会员。POST /houtai/hthysj"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = request.data.get('username', '').strip()
huiyuan_id = request.data.get('huiyuan_id', '').strip()
if not username or not huiyuan_id:
return Response({'code': 400, 'msg': '参数不完整'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '3300a' not in permissions:
return Response({'code': 403, 'msg': '无权限'})
try:
jiage = Decimal(str(request.data.get('jiage')))
guanshifc = Decimal(str(request.data.get('guanshifc')))
zuzhangfc = Decimal(str(request.data.get('zuzhangfc')))
except Exception:
return Response({'code': 400, 'msg': '金额格式错误'})
from jituan.services.club_member_admin import enable_member_for_club
extra = request.data
data, err = enable_member_for_club(
request, huiyuan_id, jiage, guanshifc, zuzhangfc, extra=extra,
)
if err:
return Response({'code': 400, 'msg': err})
return Response({'code': 0, 'msg': '上架成功', 'data': data})
class ClubMemberDisableView(APIView):
"""俱乐部下架会员。POST /houtai/hthyxj"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = request.data.get('username', '').strip()
huiyuan_id = request.data.get('huiyuan_id', '').strip()
if not username or not huiyuan_id:
return Response({'code': 400, 'msg': '参数不完整'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '3300a' not in permissions:
return Response({'code': 403, 'msg': '无权限'})
from jituan.services.club_member_admin import disable_member_for_club
data, err = disable_member_for_club(request, huiyuan_id)
if err:
return Response({'code': 400, 'msg': err})
return Response({'code': 0, 'msg': '已下架', 'data': data})
class GetGuanliListView(APIView): class GetGuanliListView(APIView):
""" """

View File

@@ -72,6 +72,12 @@ class Command(BaseCommand):
jiage=row.jiage or Decimal('0'), jiage=row.jiage or Decimal('0'),
guanshifc=row.guanshifc or Decimal('0'), guanshifc=row.guanshifc or Decimal('0'),
zuzhangfc=row.zuzhangfc or Decimal('0'), zuzhangfc=row.zuzhangfc or Decimal('0'),
formal_days=row.formal_days or 30,
trial_enabled=bool(row.trial_enabled),
trial_price=row.trial_price or Decimal('0'),
trial_days=row.trial_days or 0,
trial_guanshifc=row.trial_guanshifc or Decimal('0'),
trial_zuzhangfc=row.trial_zuzhangfc or Decimal('0'),
is_enabled=row.is_enabled, is_enabled=row.is_enabled,
bankuai_id=row.bankuai_id, bankuai_id=row.bankuai_id,
) )

View File

@@ -0,0 +1,48 @@
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0005_identity_tag'),
]
operations = [
migrations.AddField(
model_name='clubhuiyuanprice',
name='formal_days',
field=models.PositiveIntegerField(default=30, verbose_name='正式会员有效天数'),
),
migrations.AddField(
model_name='clubhuiyuanprice',
name='trial_enabled',
field=models.BooleanField(default=False, verbose_name='是否开启体验会员'),
),
migrations.AddField(
model_name='clubhuiyuanprice',
name='trial_price',
field=models.DecimalField(
decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='体验会员价格',
),
),
migrations.AddField(
model_name='clubhuiyuanprice',
name='trial_days',
field=models.PositiveIntegerField(default=0, verbose_name='体验会员有效天数'),
),
migrations.AddField(
model_name='clubhuiyuanprice',
name='trial_guanshifc',
field=models.DecimalField(
decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='体验管事分成',
),
),
migrations.AddField(
model_name='clubhuiyuanprice',
name='trial_zuzhangfc',
field=models.DecimalField(
decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='体验组长分成',
),
),
]

View File

@@ -99,6 +99,18 @@ class ClubHuiyuanPrice(QModel):
jiage = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00')) jiage = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
guanshifc = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00')) guanshifc = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
zuzhangfc = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00')) zuzhangfc = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
formal_days = models.PositiveIntegerField(default=30, verbose_name='正式会员有效天数')
trial_enabled = models.BooleanField(default=False, verbose_name='是否开启体验会员')
trial_price = models.DecimalField(
max_digits=10, decimal_places=2, default=Decimal('0.00'), verbose_name='体验会员价格',
)
trial_days = models.PositiveIntegerField(default=0, verbose_name='体验会员有效天数')
trial_guanshifc = models.DecimalField(
max_digits=10, decimal_places=2, default=Decimal('0.00'), verbose_name='体验管事分成',
)
trial_zuzhangfc = models.DecimalField(
max_digits=10, decimal_places=2, default=Decimal('0.00'), verbose_name='体验组长分成',
)
is_enabled = models.BooleanField(default=True) is_enabled = models.BooleanField(default=True)
bankuai_id = models.IntegerField(null=True, blank=True) bankuai_id = models.IntegerField(null=True, blank=True)
CreateTime = models.DateTimeField(auto_now_add=True) CreateTime = models.DateTimeField(auto_now_add=True)

View File

@@ -1,4 +1,4 @@
"""后台会员管理:全局 Huiyuan 定义 + 各俱乐部 ClubHuiyuanPrice 售价/分成。""" """后台会员管理:全局 Huiyuan 定义 + 各俱乐部 ClubHuiyuanPrice 售价/分成/体验配置"""
import logging import logging
import random import random
import string import string
@@ -14,13 +14,26 @@ from products.models import Huiyuan
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CLUB_WRITE_SCOPE_MSG = '当前为集团汇总视图,请先在顶栏切换到具体俱乐部(如星阙、星之界)后再操作' CLUB_WRITE_SCOPE_MSG = '当前为集团汇总视图,请先在顶栏切换到具体俱乐部后再操作'
def club_write_blocked(request): def club_write_blocked(request):
return resolve_club_scope(request) == DATA_SCOPE_ALL 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): def _format_member(m, price_row=None):
global_jiage = str(m.jiage) global_jiage = str(m.jiage)
global_guanshifc = str(m.guanshifc) global_guanshifc = str(m.guanshifc)
@@ -41,6 +54,12 @@ def _format_member(m, price_row=None):
'bankuai_id': m.bankuai_id, 'bankuai_id': m.bankuai_id,
'club_enabled': True, 'club_enabled': True,
'price_source': 'global', '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: if price_row is not None:
item['jiage'] = str(price_row.jiage) item['jiage'] = str(price_row.jiage)
@@ -48,6 +67,7 @@ def _format_member(m, price_row=None):
item['zuzhangfc'] = str(price_row.zuzhangfc) item['zuzhangfc'] = str(price_row.zuzhangfc)
item['club_enabled'] = bool(price_row.is_enabled) item['club_enabled'] = bool(price_row.is_enabled)
item['price_source'] = 'club' item['price_source'] = 'club'
item.update(_price_row_to_dict(price_row))
return item return item
@@ -81,20 +101,70 @@ def build_member_list_payload(request):
'club_id': club_id, 'club_id': club_id,
'scope': scope, 'scope': scope,
'price_note': ( 'price_note': (
f'当前俱乐部 {club_id} 专属售价(club_huiyuan_price' f'当前俱乐部 {club_id} 专属售价club_huiyuan_price'
'若与星阙相同,是创建时从 xq 复制的默认值,请在本俱乐部视图下修改价格。'
if scope != DATA_SCOPE_ALL else if scope != DATA_SCOPE_ALL else
'集团汇总:展示全局默认价。请切换具体俱乐部查看/编辑专属价' '集团汇总:展示全局默认价切换具体俱乐部可配置售价与体验会员'
), ),
**list_response_meta(request), **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): if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG return None, CLUB_WRITE_SCOPE_MSG
club_id = club_id_for_write(request) 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(): with transaction.atomic():
try: try:
@@ -115,6 +185,7 @@ def update_member_for_club(request, huiyuan_id, jieshao, jiage, guanshifc, zuzha
'zuzhangfc': zuzhangfc, 'zuzhangfc': zuzhangfc,
'is_enabled': True, 'is_enabled': True,
'bankuai_id': member.bankuai_id, 'bankuai_id': member.bankuai_id,
**trial_fields,
}, },
) )
if not created: if not created:
@@ -122,6 +193,8 @@ def update_member_for_club(request, huiyuan_id, jieshao, jiage, guanshifc, zuzha
row.guanshifc = guanshifc row.guanshifc = guanshifc
row.zuzhangfc = zuzhangfc row.zuzhangfc = zuzhangfc
row.is_enabled = True row.is_enabled = True
for k, v in trial_fields.items():
setattr(row, k, v)
row.save() row.save()
if club_id == CLUB_ID_DEFAULT: 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 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): if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG return None, CLUB_WRITE_SCOPE_MSG
club_id = club_id_for_write(request) 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(): def generate_huiyuan_id():
while True: while True:
@@ -160,20 +288,18 @@ def add_member_for_club(request, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao
if bankuai_id is not None: if bankuai_id is not None:
create_kwargs['bankuai_id'] = bankuai_id create_kwargs['bankuai_id'] = bankuai_id
member = Huiyuan.query.create(**create_kwargs) 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: except IntegrityError as e:
logger.exception('添加会员失败 club_id=%s', club_id) logger.exception('创建全局会员失败')
return None, f'添加失败:数据冲突({e}' return None, f'创建失败:{e}'
except Exception as e: except Exception as e:
logger.exception('添加会员失败 club_id=%s', club_id) logger.exception('创建全局会员失败')
return None, f'添加失败:{e}' return None, f'创建失败:{e}'
return {'huiyuan_id': member.huiyuan_id}, None 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 @@
""" """
会员微信充值履约(回调 / 前端确认共用,幂等)。 会员微信充值履约(回调 / 前端确认共用,幂等)。
分红策略(定稿): 分红策略(定稿 v2
- 第几次购买 cishu本俱乐部该打手该会员,已支付成功 czjilu 笔数(不含本单)+ 1。 - formal_cishu本俱乐部 + 打手 + 会员czjilu 已支付正式单(is_trial=false)笔数 + 1。
与换管事无关,不看 gsfenhong不看管事被分了几次 与换管事无关;体验单不计入
- cishu=1duoci_fenhong(club_id+受益人+会员+cishu);无则 club_huiyuan_price 默认分成。 - formal_cishu=1duoci_fenhong 或 club 默认正式分成。
- cishu>=2仅 duoci_fenhong无配置则不分(不读管事/组长用户表二次或永久分红字段) - formal_cishu>=2仅 duoci_fenhong无配置则不分。
- 价格与默认分成:仅 club_huiyuan_price + order.club_id不读全局 huiyuan 表 - 体验单:固定 trial_guanshifc / trial_zuzhangfc不计入 formal_cishu
财务:每笔成功履约必须 czjilu.zhuangtai=3 + 收支入账record_wechat_income_once
""" """
from __future__ import annotations from __future__ import annotations
@@ -40,6 +42,9 @@ from users.models import UserDashou, UserGuanshi, UserZuzhang
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MEMBER_FENHONG_LEIXING = 1 MEMBER_FENHONG_LEIXING = 1
MEMBER_LEIXING = 1
MEMBER_PAID_STATUS = 3
MEMBER_PENDING_STATUS = 9
class MemberRechargeError(Exception): class MemberRechargeError(Exception):
@@ -48,35 +53,110 @@ class MemberRechargeError(Exception):
self.code = code self.code = code
def club_huiyuan_sellable(club_id, huiyuan_id): def get_club_huiyuan_row(club_id, huiyuan_id, require_enabled=False):
"""仅 club_huiyuan_price 可售;未配置则不可购买。"""
club_id = club_id or CLUB_ID_DEFAULT club_id = club_id or CLUB_ID_DEFAULT
row = ClubHuiyuanPrice.query.filter( qs = ClubHuiyuanPrice.query.filter(club_id=club_id, huiyuan_id=huiyuan_id)
club_id=club_id, huiyuan_id=huiyuan_id, is_enabled=True, 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() ).first()
if row and row.jiage and Decimal(str(row.jiage)) > 0: if goumai and goumai.has_used_trial:
return True, Decimal(str(row.jiage)) return False, '体验会员仅可购买一次'
return False, Decimal('0')
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 打手第几次正式购买该会员duoci_fenhong.cishu
同俱乐部 czjilu 已支付笔数(不含本单)+ 1 仅统计 is_trial=false 的已支付 czjilu
""" """
club_id = club_id or CLUB_ID_DEFAULT club_id = club_id or CLUB_ID_DEFAULT
prior_paid = Czjilu.query.filter( prior_paid = Czjilu.query.filter(
yonghuid=yonghuid, yonghuid=yonghuid,
huiyuan_id=huiyuan_id, huiyuan_id=huiyuan_id,
leixing=1, leixing=MEMBER_LEIXING,
zhuangtai=3, zhuangtai=MEMBER_PAID_STATUS,
club_id=club_id, club_id=club_id,
is_trial=False,
).exclude(dingdan_id=current_dingdan_id).count() ).exclude(dingdan_id=current_dingdan_id).count()
return prior_paid + 1 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): def _lookup_duoci_fenhong(club_id, huiyuan_id, beneficiary_id, cishu, field):
"""有行则返回金额(含 0无行返回 None 表示未配置。"""
try: try:
row = DuociFenhong.query.get( row = DuociFenhong.query.get(
club_id=club_id or CLUB_ID_DEFAULT, 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 return None
def calc_guanshi_member_fenhong( def calc_guanshi_member_fenhong(huiyuan_id, guanshi_id, cishu, club_id, default_guanshi_fc):
huiyuan_id, guanshi_id, cishu, club_id, default_guanshi_fc,
):
"""cishu>=2 只认 duoci_fenhongcishu=1 可回落 club 默认分成。"""
club_id = club_id or CLUB_ID_DEFAULT club_id = club_id or CLUB_ID_DEFAULT
custom = _lookup_duoci_fenhong(club_id, huiyuan_id, guanshi_id, cishu, 'guanshi_fenhong') custom = _lookup_duoci_fenhong(club_id, huiyuan_id, guanshi_id, cishu, 'guanshi_fenhong')
if custom is not None: if custom is not None:
return custom if custom > 0 else None return custom if custom > 0 else None
if cishu == 1: if cishu == 1:
base = default_guanshi_fc if default_guanshi_fc is not None else Decimal('0') base = default_guanshi_fc if default_guanshi_fc is not None else Decimal('0')
return base if base > 0 else None return base if base > 0 else None
return None return None
def calc_zuzhang_member_fenhong( def calc_zuzhang_member_fenhong(huiyuan_id, zuzhang_id, cishu, club_id, default_zuzhang_fc):
huiyuan_id, zuzhang_id, cishu, club_id, default_zuzhang_fc,
):
if not zuzhang_id: if not zuzhang_id:
return None return None
club_id = club_id or CLUB_ID_DEFAULT club_id = club_id or CLUB_ID_DEFAULT
custom = _lookup_duoci_fenhong(club_id, huiyuan_id, zuzhang_id, cishu, 'zuzhang_fenhong') custom = _lookup_duoci_fenhong(club_id, huiyuan_id, zuzhang_id, cishu, 'zuzhang_fenhong')
if custom is not None: if custom is not None:
return custom if custom > 0 else None return custom if custom > 0 else None
if cishu == 1: if cishu == 1:
base = default_zuzhang_fc if default_zuzhang_fc is not None else Decimal('0') base = default_zuzhang_fc if default_zuzhang_fc is not None else Decimal('0')
return base if base > 0 else None return base if base > 0 else None
return 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(): if Gsfenhong.query.filter(dingdan_id=order.dingdan_id).exists():
logger.info('会员分红已处理,跳过: %s', order.dingdan_id) logger.info('会员分红已处理,跳过: %s', order.dingdan_id)
return return
@@ -154,25 +296,30 @@ def _apply_member_fenhong(order, huiyuan, purchase_cishu):
club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT
huiyuan_id = huiyuan.huiyuan_id 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)) order_amount = Decimal(str(order.jine))
is_trial_order = bool(getattr(order, 'is_trial', False))
guanshi_fenhong = calc_guanshi_member_fenhong( if is_trial_order:
huiyuan_id, guanshi_id, cishu, club_id, guanshi_fc, guanshi_fenhong = Decimal(str(trial_guanshi_fc or 0))
) zuzhang_fenhong = Decimal(str(trial_zuzhang_fc or 0)) if zuzhang_id else Decimal('0')
guanshi_fenhong = guanshi_fenhong if guanshi_fenhong is not None else Decimal('0') cishu = None
else:
zuzhang_fenhong = calc_zuzhang_member_fenhong( cishu = formal_cishu or resolve_formal_purchase_cishu(
huiyuan_id, zuzhang_id, cishu, club_id, zuzhang_fc, order.yonghuid, huiyuan_id, order.dingdan_id, club_id,
) )
zuzhang_fenhong = zuzhang_fenhong if zuzhang_fenhong is not None else Decimal('0') 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 total = guanshi_fenhong + zuzhang_fenhong
if total > order_amount: if total > order_amount:
logger.warning( logger.warning('分红总额%s超订单%s组长置0', total, order_amount)
'分红总额%s超订单%s组长置0', total, order_amount,
)
zuzhang_fenhong = Decimal('0') zuzhang_fenhong = Decimal('0')
total = guanshi_fenhong total = guanshi_fenhong
if total > order_amount: if total > order_amount:
@@ -181,8 +328,8 @@ def _apply_member_fenhong(order, huiyuan, purchase_cishu):
if guanshi_fenhong <= 0 and zuzhang_fenhong <= 0: if guanshi_fenhong <= 0 and zuzhang_fenhong <= 0:
logger.info( logger.info(
'本次无会员分红 dingdan=%s club=%s cishu=%s', '本次无会员分红 dingdan=%s club=%s cishu=%s is_trial=%s',
order.dingdan_id, club_id, cishu, order.dingdan_id, club_id, cishu, is_trial_order,
) )
return return
@@ -218,66 +365,30 @@ def _apply_member_fenhong(order, huiyuan, purchase_cishu):
huiyuan_id=huiyuan_id, huiyuan_id=huiyuan_id,
fenhong_leixing=MEMBER_FENHONG_LEIXING, fenhong_leixing=MEMBER_FENHONG_LEIXING,
club_id=resolve_gsfenhong_club_id(dingdan_id=order.dingdan_id, czjilu=order), 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: try:
if cishu == 1: if cishu == 1:
update_guanshi_daily_by_action( update_guanshi_daily_by_action(yonghuid=guanshi_id, action=2, amount=guanshi_fenhong)
yonghuid=guanshi_id, action=2, amount=guanshi_fenhong,
)
else: else:
update_guanshi_xufei_daily( update_guanshi_xufei_daily(yonghuid=guanshi_id, xufei_jine=guanshi_fenhong)
yonghuid=guanshi_id, xufei_jine=guanshi_fenhong,
)
except Exception as exc: except Exception as exc:
logger.error('管事日统计更新失败: %s', exc) logger.error('管事日统计更新失败: %s', exc)
if zuzhang_fenhong > 0 and zuzhang_id: if zuzhang_fenhong > 0 and zuzhang_id:
try: try:
update_zuzhang_daily_by_action( update_zuzhang_daily_by_action(yonghuid=zuzhang_id, action=2, amount=zuzhang_fenhong)
yonghuid=zuzhang_id, action=2, amount=zuzhang_fenhong,
)
except Exception as exc: except Exception as exc:
logger.error('组长日统计更新失败: %s', exc) logger.error('组长日统计更新失败: %s', exc)
logger.info( logger.info(
'会员分红完成 dingdan=%s club=%s cishu=%s 管事=%s 组长=%s', '会员分红完成 dingdan=%s club=%s formal_cishu=%s is_trial=%s 管事=%s 组长=%s',
order.dingdan_id, club_id, cishu, guanshi_fenhong, zuzhang_fenhong, 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): def _add_first_buy_jifen(yonghuid):
try: try:
dashou = UserDashou.query.get(user__UserUID=yonghuid) 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): def repair_member_entitlement_if_paid(dingdan_id):
""" order = Czjilu.query.filter(dingdan_id=dingdan_id, leixing=MEMBER_LEIXING).first()
订单已标为已支付(zhuangtai=3)但 huiyuangoumai 缺失时补开通会员。 if not order or order.zhuangtai != MEMBER_PAID_STATUS or not order.huiyuan_id:
用于修复历史脏数据或履约中断。
"""
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 return False
exists = Huiyuangoumai.query.filter( exists = Huiyuangoumai.query.filter(
yonghu_id=order.yonghuid, yonghu_id=order.yonghuid, huiyuan_id=order.huiyuan_id,
huiyuan_id=order.huiyuan_id,
).exists() ).exists()
if exists: if exists:
return True return True
@@ -306,44 +412,40 @@ def repair_member_entitlement_if_paid(dingdan_id):
try: try:
huiyuan = Huiyuan.query.get(huiyuan_id=order.huiyuan_id) huiyuan = Huiyuan.query.get(huiyuan_id=order.huiyuan_id)
except Huiyuan.DoesNotExist: 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 return False
club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT
days = order.purchase_days or 30
with transaction.atomic(): with transaction.atomic():
_apply_huiyuan_goumai(order.yonghuid, order.huiyuan_id, huiyuan, club_id) _apply_huiyuan_entitlement(
logger.warning('已补开通会员 dingdan=%s yonghu=%s huiyuan=%s', dingdan_id, order.yonghuid, order.huiyuan_id) order.yonghuid, order.huiyuan_id, huiyuan, club_id,
bool(getattr(order, 'is_trial', False)), days,
)
logger.warning('已补开通会员 dingdan=%s', dingdan_id)
return True return True
@transaction.atomic @transaction.atomic
def fulfill_member_recharge(dingdan_id, paid_amount_yuan=None, source='callback'): 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() locked = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update()
order = locked.first() order = locked.first()
if not order: if not order:
raise MemberRechargeError('订单不存在', 'not_found') raise MemberRechargeError('订单不存在', 'not_found')
if order.leixing != 1: if order.leixing != MEMBER_LEIXING:
raise MemberRechargeError('订单类型不是会员充值', 'type_error') raise MemberRechargeError('订单类型不是会员充值', 'type_error')
if order.zhuangtai != 9: if order.zhuangtai != MEMBER_PENDING_STATUS:
logger.info( logger.info(
'会员订单已处理,跳过履约 dingdan=%s zhuangtai=%s source=%s', '会员订单已处理,跳过履约 dingdan=%s zhuangtai=%s source=%s',
dingdan_id, order.zhuangtai, source, dingdan_id, order.zhuangtai, source,
) )
try: try:
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT
repair_wechat_income_if_missing( _record_member_income(order, club_id)
Decimal(str(order.jine)), except MemberRechargeError:
getattr(order, 'club_id', None) or CLUB_ID_DEFAULT, pass
biz_ref=f'cz:{dingdan_id}',
)
except Exception as exc:
logger.error('会员补记收支失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True)
return { return {
'already_done': True, 'already_done': True,
'dingdan_id': dingdan_id, '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') raise MemberRechargeError(f'会员不存在: {order.huiyuan_id}', 'huiyuan_not_found')
club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT 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( formal_cishu = None
yonghu_id=order.yonghuid, trial_guanshi_fc = Decimal('0')
huiyuan_id=order.huiyuan_id, trial_zuzhang_fc = Decimal('0')
).exists() 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 之前算清第几次购买 goumai_record, is_first_buy_any = _apply_huiyuan_entitlement(
purchase_cishu = resolve_member_purchase_cishu( order.yonghuid, order.huiyuan_id, huiyuan, club_id, is_trial, purchase_days,
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: if is_first_buy_any:
_add_first_buy_jifen(order.yonghuid) _add_first_buy_jifen(order.yonghuid)
order.zhuangtai = 3 order.zhuangtai = MEMBER_PAID_STATUS
order.save(update_fields=['zhuangtai']) order.save(update_fields=['zhuangtai'])
huiyuan.goumai_cishu = (huiyuan.goumai_cishu or 0) + 1 huiyuan.goumai_cishu = (huiyuan.goumai_cishu or 0) + 1
huiyuan.save(update_fields=['goumai_cishu']) huiyuan.save(update_fields=['goumai_cishu'])
jine_decimal = Decimal(str(order.jine)) _record_member_income(order, club_id)
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: 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: except Exception as exc:
logger.error( logger.error(
'会员分红失败但会员已开通 dingdan=%s err=%s', '会员分红失败但会员已开通 dingdan=%s err=%s', dingdan_id, exc, exc_info=True,
dingdan_id, exc, exc_info=True,
) )
logger.info( logger.info(
'会员充值履约完成 dingdan=%s source=%s club=%s purchase_cishu=%s first_member=%s', '会员充值履约完成 dingdan=%s source=%s club=%s is_trial=%s formal_cishu=%s jine=%s',
dingdan_id, source, club_id, purchase_cishu, is_first_purchase_of_member, dingdan_id, source, club_id, is_trial, formal_cishu, order.jine,
) )
return { return {
'already_done': False, 'already_done': False,
'dingdan_id': dingdan_id, 'dingdan_id': dingdan_id,
'zhuangtai': 3, 'zhuangtai': MEMBER_PAID_STATUS,
'purchase_cishu': purchase_cishu, 'formal_cishu': formal_cishu,
'is_first_purchase_of_member': is_first_purchase_of_member, 'is_trial': is_trial,
'daoqi_time': goumai_record.daoqi_time, 'daoqi_time': goumai_record.daoqi_time,
'huiyuan_id': order.huiyuan_id, 'huiyuan_id': order.huiyuan_id,
} }
def query_wechat_v2_trade_state(out_trade_no, club_id=None): 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) club_id = club_id or club_id_from_czjilu(out_trade_no)
cfg = get_wechat_v2_config(club_id) cfg = get_wechat_v2_config(club_id)
nonce_str = ''.join(random.choices(string.ascii_letters + string.digits, k=32)) 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): def confirm_member_recharge_paid(dingdan_id, yonghuid):
"""
前端支付成功确认:先查微信 SUCCESS 再幂等履约。
已履约则直接返回成功,不与回调重复入账。
"""
try: try:
order = Czjilu.query.get(dingdan_id=dingdan_id, yonghuid=yonghuid) order = Czjilu.query.get(dingdan_id=dingdan_id, yonghuid=yonghuid)
except Czjilu.DoesNotExist: except Czjilu.DoesNotExist:
raise MemberRechargeError('订单不存在或不属于当前用户', 'not_found') raise MemberRechargeError('订单不存在或不属于当前用户', 'not_found')
if order.leixing != 1: if order.leixing != MEMBER_LEIXING:
raise MemberRechargeError('不是会员订单', 'type_error') raise MemberRechargeError('不是会员订单', 'type_error')
if order.zhuangtai == 3: if order.zhuangtai == MEMBER_PAID_STATUS:
goumai = Huiyuangoumai.query.filter( goumai = Huiyuangoumai.query.filter(
yonghu_id=yonghuid, huiyuan_id=order.huiyuan_id, yonghu_id=yonghuid, huiyuan_id=order.huiyuan_id,
).first() ).first()
@@ -482,12 +584,13 @@ def confirm_member_recharge_paid(dingdan_id, yonghuid):
return { return {
'fulfilled': False, 'fulfilled': False,
'already_done': True, 'already_done': True,
'zhuangtai': 3, 'zhuangtai': MEMBER_PAID_STATUS,
'daoqi_time': goumai.daoqi_time, 'daoqi_time': goumai.daoqi_time,
'huiyuan_id': order.huiyuan_id, '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') raise MemberRechargeError(f'订单状态异常: {order.zhuangtai}', 'status_error')
trade_state, _ = query_wechat_v2_trade_state( 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') result['fulfilled'] = not result.get('already_done')
return result 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

View File

@@ -2,6 +2,9 @@ from django.urls import path
from backend.view import ( from backend.view import (
AddMemberView, AddMemberView,
ClubMemberCatalogView,
ClubMemberDisableView,
ClubMemberEnableView,
FaKuanChuLiView, FaKuanChuLiView,
FaKuanChuangJianView, FaKuanChuangJianView,
FaKuanLieBiaoView, FaKuanLieBiaoView,
@@ -96,5 +99,8 @@ urlpatterns = [
path('houtai/hthqhylb', GetMemberListView.as_view(), name='jituan_member_list'), path('houtai/hthqhylb', GetMemberListView.as_view(), name='jituan_member_list'),
path('houtai/htxghyxx', UpdateMemberView.as_view(), name='jituan_member_update'), path('houtai/htxghyxx', UpdateMemberView.as_view(), name='jituan_member_update'),
path('houtai/httjhy', AddMemberView.as_view(), name='jituan_member_add'), path('houtai/httjhy', AddMemberView.as_view(), name='jituan_member_add'),
path('houtai/hthycatalog', ClubMemberCatalogView.as_view(), name='jituan_member_catalog'),
path('houtai/hthysj', ClubMemberEnableView.as_view(), name='jituan_member_enable'),
path('houtai/hthyxj', ClubMemberDisableView.as_view(), name='jituan_member_disable'),
path('club/list', ClubListView.as_view(), name='jituan_club_list'), path('club/list', ClubListView.as_view(), name='jituan_club_list'),
] ]

View File

@@ -0,0 +1,38 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0005_duoci_fenhong_club_id'),
]
operations = [
migrations.AddField(
model_name='huiyuangoumai',
name='is_trial',
field=models.BooleanField(default=False, verbose_name='当前是否为体验权益'),
),
migrations.AddField(
model_name='huiyuangoumai',
name='has_used_trial',
field=models.BooleanField(default=False, verbose_name='是否已购买过体验版'),
),
migrations.AddField(
model_name='czjilu',
name='is_trial',
field=models.BooleanField(default=False, verbose_name='是否体验会员单'),
),
migrations.AddField(
model_name='czjilu',
name='purchase_days',
field=models.PositiveIntegerField(
blank=True, null=True, verbose_name='下单锁定有效天数',
),
),
migrations.AddField(
model_name='gsfenhong',
name='is_trial',
field=models.BooleanField(default=False, verbose_name='是否体验会员分红'),
),
]

View File

@@ -172,6 +172,8 @@ class Huiyuangoumai(QModel):
club_id = models.CharField( club_id = models.CharField(
max_length=16, default='xq', db_index=True, verbose_name='所属俱乐部', max_length=16, default='xq', db_index=True, verbose_name='所属俱乐部',
) )
is_trial = models.BooleanField(default=False, verbose_name='当前是否为体验权益')
has_used_trial = models.BooleanField(default=False, verbose_name='是否已购买过体验版')
class Meta: class Meta:
db_table = 'huiyuangoumai' db_table = 'huiyuangoumai'
@@ -218,6 +220,10 @@ class Czjilu(QModel):
club_id = models.CharField( club_id = models.CharField(
max_length=16, default='xq', db_index=True, verbose_name='所属俱乐部', max_length=16, default='xq', db_index=True, verbose_name='所属俱乐部',
) )
is_trial = models.BooleanField(default=False, verbose_name='是否体验会员单')
purchase_days = models.PositiveIntegerField(
null=True, blank=True, verbose_name='下单锁定有效天数',
)
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间') UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间')
class Meta: class Meta:
@@ -258,6 +264,7 @@ class Gsfenhong(QModel):
club_id = models.CharField( club_id = models.CharField(
max_length=16, default='xq', db_index=True, verbose_name='所属俱乐部', max_length=16, default='xq', db_index=True, verbose_name='所属俱乐部',
) )
is_trial = models.BooleanField(default=False, verbose_name='是否体验会员分红')
class Meta: class Meta:
db_table = 'gsfenhong' db_table = 'gsfenhong'

View File

@@ -267,7 +267,7 @@ class DashouHuiyuanList(APIView):
}, status=status.HTTP_400_BAD_REQUEST) }, status=status.HTTP_400_BAD_REQUEST)
# 3. 仅返回本俱乐部可售会员club_huiyuan_price 已配置且启用) # 3. 仅返回本俱乐部可售会员club_huiyuan_price 已配置且启用)
from jituan.services.member_recharge import club_huiyuan_sellable from jituan.services.member_recharge import club_huiyuan_sellable, can_purchase_trial
club_id = resolve_effective_club_id(request, request.user) club_id = resolve_effective_club_id(request, request.user)
huiyuan_queryset = Huiyuan.query.all().only( huiyuan_queryset = Huiyuan.query.all().only(
@@ -275,15 +275,27 @@ class DashouHuiyuanList(APIView):
).order_by('jiage') ).order_by('jiage')
huiyuan_list = [] huiyuan_list = []
yonghuid = request.user.UserUID
for huiyuan in huiyuan_queryset: for huiyuan in huiyuan_queryset:
sellable, club_price = club_huiyuan_sellable(club_id, huiyuan.huiyuan_id) sellable, club_price, formal_days = club_huiyuan_sellable(
club_id, huiyuan.huiyuan_id, is_trial=False,
)
if not sellable or not club_price: if not sellable or not club_price:
continue continue
trial_ok, _ = can_purchase_trial(yonghuid, huiyuan.huiyuan_id, club_id)
trial_sellable, trial_price, trial_days = club_huiyuan_sellable(
club_id, huiyuan.huiyuan_id, is_trial=True,
)
huiyuan_list.append({ huiyuan_list.append({
'id': huiyuan.huiyuan_id, 'id': huiyuan.huiyuan_id,
'mingzi': huiyuan.jieshao, 'mingzi': huiyuan.jieshao,
'jiage': float(club_price), 'jiage': float(club_price),
'formal_days': formal_days,
'jieshao': huiyuan.jtjieshao, 'jieshao': huiyuan.jtjieshao,
'trial_enabled': bool(trial_sellable),
'trial_price': float(trial_price) if trial_sellable else 0,
'trial_days': trial_days if trial_sellable else 0,
'can_buy_trial': bool(trial_ok and trial_sellable),
}) })
# 5. 返回数据 # 5. 返回数据
@@ -1381,6 +1393,7 @@ class HuiyuanGoumai(APIView):
try: try:
# 1. 获取会员ID参数 # 1. 获取会员ID参数
huiyuanid = request.data.get('huiyuanid') huiyuanid = request.data.get('huiyuanid')
is_trial = bool(request.data.get('is_trial') or request.data.get('isTrial'))
if not huiyuanid: if not huiyuanid:
return Response({ return Response({
'code': 400, 'code': 400,
@@ -1413,12 +1426,15 @@ class HuiyuanGoumai(APIView):
}, status=status.HTTP_400_BAD_REQUEST) }, status=status.HTTP_400_BAD_REQUEST)
club_id = resolve_effective_club_id(request, request.user) club_id = resolve_effective_club_id(request, request.user)
from jituan.services.member_recharge import club_huiyuan_sellable from jituan.services.member_recharge import validate_member_purchase
sellable, club_price = club_huiyuan_sellable(club_id, huiyuanid)
if not sellable: ok, err_msg, club_price, purchase_days = validate_member_purchase(
request.user.UserUID, huiyuanid, club_id, is_trial,
)
if not ok:
return Response({ return Response({
'code': 400, 'code': 400,
'message': '该俱乐部未配置此会员价格,请联系管理员' 'message': err_msg or '不可购买',
}, status=status.HTTP_400_BAD_REQUEST) }, status=status.HTTP_400_BAD_REQUEST)
# 5. 生成订单ID前缀CzHY表示会员充值 # 5. 生成订单ID前缀CzHY表示会员充值
@@ -1442,11 +1458,14 @@ class HuiyuanGoumai(APIView):
shuoming=shuoming, shuoming=shuoming,
huiyuan_id=huiyuanid, # 存储会员ID huiyuan_id=huiyuanid, # 存储会员ID
club_id=club_id, club_id=club_id,
is_trial=is_trial,
purchase_days=int(purchase_days),
) )
logger.info( logger.info(
f"创建会员充值订单: 用户{request.user.UserUID}, 订单{dingdanid}, " f"创建会员充值订单: 用户{request.user.UserUID}, 订单{dingdanid}, "
f"会员{huiyuanid}, 俱乐部{club_id}, 金额{jine}") f"会员{huiyuanid}, 俱乐部{club_id}, 体验={is_trial}, "
f"金额{jine}元, 天数{purchase_days}")
# 7. 生成微信支付参数 # 7. 生成微信支付参数
try: try:
@@ -1477,6 +1496,8 @@ class HuiyuanGoumai(APIView):
'dingdanid': dingdanid, 'dingdanid': dingdanid,
'club_id': club_id, 'club_id': club_id,
'jine': jine, 'jine': jine,
'is_trial': is_trial,
'purchase_days': purchase_days,
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
except Exception as e: except Exception as e:
@@ -5862,8 +5883,11 @@ class DsqrgmdhView(APIView):
if shenfen_id not in [1, 2, 3, 4]: if shenfen_id not in [1, 2, 3, 4]:
return Response({'code': 400, 'msg': 'shenfen_id 必须为 1/2/3/4'}, status=status.HTTP_400_BAD_REQUEST) return Response({'code': 400, 'msg': 'shenfen_id 必须为 1/2/3/4'}, status=status.HTTP_400_BAD_REQUEST)
if leixing == 1 and not huiyuan_id: if leixing == 1:
return Response({'code': 400, 'msg': '购买会员时必须提供 huiyuan_id'}, status=status.HTTP_400_BAD_REQUEST) return Response({
'code': 400,
'msg': '会员购买仅支持微信支付,请使用会员充值页面',
}, status=status.HTTP_400_BAD_REQUEST)
if leixing == 2: if leixing == 2:
if not yajin_jine: if not yajin_jine:
return Response({'code': 400, 'msg': '购买押金时必须提供 yajin_jine'}, status=status.HTTP_400_BAD_REQUEST) return Response({'code': 400, 'msg': '购买押金时必须提供 yajin_jine'}, status=status.HTTP_400_BAD_REQUEST)

View File

@@ -2024,6 +2024,10 @@ class TixianShenqingView(APIView):
if dashou.yue < jine: if dashou.yue < jine:
return {'code': 13, 'msg': f'余额不足,当前余额: {dashou.yue}'} return {'code': 13, 'msg': f'余额不足,当前余额: {dashou.yue}'}
from jituan.services.member_recharge import dashou_in_active_trial_period
if dashou_in_active_trial_period(user_main.UserUID):
return {'code': 16, 'msg': '体验会员期间不可提现佣金,请升级正式会员或待体验到期'}
return {'code': 0, 'msg': 'ok', 'data': {'nicheng': dashou.nicheng or ''}} return {'code': 0, 'msg': 'ok', 'data': {'nicheng': dashou.nicheng or ''}}
def _check_guanshi_dividend_withdraw(self, user_main, jine): def _check_guanshi_dividend_withdraw(self, user_main, jine):
@@ -8893,35 +8897,27 @@ class KefuUpdateDashouView(APIView):
return Response({'code': 403, 'msg': '无权限给打手加会员'}) return Response({'code': 403, 'msg': '无权限给打手加会员'})
if not huiyuan_id: if not huiyuan_id:
return Response({'code': 400, 'msg': '缺少会员ID'}) return Response({'code': 400, 'msg': '缺少会员ID'})
days = int(value) if value else 30 # 默认加30天
try: try:
huiyuan = Huiyuan.query.get(huiyuan_id=huiyuan_id) huiyuan = Huiyuan.query.get(huiyuan_id=huiyuan_id)
except Huiyuan.DoesNotExist: except Huiyuan.DoesNotExist:
return Response({'code': 404, 'msg': '会员类型不存在'}) return Response({'code': 404, 'msg': '会员类型不存在'})
# 获取或创建购买记录 from jituan.services.club_user import get_user_club_id
record, created = Huiyuangoumai.objects.select_for_update().get_or_create( from jituan.services.member_recharge import extend_member_entitlement_admin, get_club_huiyuan_row
yonghu_id=dashou_id, player_club = get_user_club_id(dashou_user)
huiyuan_id=huiyuan_id, row = get_club_huiyuan_row(player_club, huiyuan_id, require_enabled=False)
defaults={ days = int(value) if value else int(row.formal_days or 30) if row else 30
'huiyuan_zhuangtai': 1, record, err = extend_member_entitlement_admin(
'jieshao': huiyuan.jieshao, dashou_id, huiyuan_id, days, player_club,
'daoqi_time': timezone.now() + timedelta(days=days),
}
) )
if not created: if err:
# 延长有效期 return Response({'code': 400, 'msg': err})
new_daoqi = record.daoqi_time + timedelta(days=days)
record.daoqi_time = new_daoqi
record.huiyuan_zhuangtai = 1
record.save()
# 记录修改日志
Xiugaijilu.query.create( Xiugaijilu.query.create(
yonghuid=dashou_id, yonghuid=dashou_id,
xiugaiid=kefu.user.Phone, xiugaiid=kefu.user.Phone,
leixing=2, leixing=2,
huiyuants=days, huiyuants=days,
huiyuan_id=huiyuan_id, huiyuan_id=huiyuan_id,
qitashuoming=f'加会员 {huiyuan_id},增加{days}', qitashuoming=f'正式会员 {huiyuan_id},增加{days}(无支付单、无分红)',
) )
return Response({'code': 0, 'msg': '加会员成功', 'data': {'new_daoqi': record.daoqi_time.strftime('%Y-%m-%d %H:%M:%S')}}) return Response({'code': 0, 'msg': '加会员成功', 'data': {'new_daoqi': record.daoqi_time.strftime('%Y-%m-%d %H:%M:%S')}})