From 2f0838e5524cdb661e309d8d45493687a4e2b1b4 Mon Sep 17 00:00:00 2001 From: XingQue Date: Fri, 26 Jun 2026 01:32:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=9A=E5=91=98=E4=BD=93=E9=AA=8C?= =?UTF-8?q?=E7=89=88=E3=80=81=E4=BF=B1=E4=B9=90=E9=83=A8=E5=88=86=E7=A6=BB?= =?UTF-8?q?=E4=B8=8A=E6=9E=B6=E4=B8=8E=E5=88=86=E7=BA=A2=E8=AE=A1=E6=95=B0?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扩展 club_huiyuan_price/huiyuangoumai/czjilu/gsfenhong 字段;微信购会员强制写 czjilu+收支入账;formal_cishu 仅统计正式已支付单;关闭余额购会员;俱乐部上架/下架 API。 Co-authored-by: Cursor --- backend/view.py | 122 ++++- jituan/management/commands/create_club.py | 6 + .../0006_club_huiyuan_trial_fields.py | 48 ++ jituan/models.py | 12 + jituan/services/club_member_admin.py | 168 ++++++- jituan/services/member_recharge.py | 429 ++++++++++++------ jituan/urls.py | 6 + .../migrations/0006_member_trial_fields.py | 38 ++ products/models.py | 7 + products/views.py | 42 +- users/views.py | 32 +- 11 files changed, 689 insertions(+), 221 deletions(-) create mode 100644 jituan/migrations/0006_club_huiyuan_trial_fields.py create mode 100644 products/migrations/0006_member_trial_fields.py diff --git a/backend/view.py b/backend/view.py index 483c02a..94d887e 100644 --- a/backend/view.py +++ b/backend/view.py @@ -1276,28 +1276,20 @@ class KefuUpdateDashouView(APIView): return Response({'code': 403, 'msg': '无权限给打手加会员'}) if not huiyuan_id: return Response({'code': 400, 'msg': '缺少会员ID'}) - days = int(value) if value else 30 try: huiyuan = Huiyuan.query.get(huiyuan_id=huiyuan_id) except Huiyuan.DoesNotExist: return Response({'code': 404, 'msg': '会员类型不存在'}) 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) - record, created = Huiyuangoumai.objects.select_for_update().get_or_create( - yonghu_id=dashou_id, - huiyuan_id=huiyuan_id, - defaults={ - 'huiyuan_zhuangtai': 1, - 'jieshao': huiyuan.jieshao, - 'daoqi_time': timezone.now() + timedelta(days=days), - 'club_id': player_club, - } + row = get_club_huiyuan_row(player_club, huiyuan_id, require_enabled=False) + days = int(value) if value else int(row.formal_days or 30) if row else 30 + record, err = extend_member_entitlement_admin( + dashou_id, huiyuan_id, days, player_club, ) - if not created: - new_daoqi = record.daoqi_time + timedelta(days=days) - record.daoqi_time = new_daoqi - record.huiyuan_zhuangtai = 1 - record.save() + if err: + return Response({'code': 400, 'msg': err}) write_xiugai_log( yonghuid=dashou_id, xiugaiid=kefu.user.Phone, @@ -1305,9 +1297,9 @@ class KefuUpdateDashouView(APIView): huiyuants=days, huiyuan_id=huiyuan_id, 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'打手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')}}) @@ -2827,8 +2819,16 @@ class UpdateMemberView(APIView): 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( - request, huiyuan_id, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, + request, huiyuan_id, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, extra=extra, ) if err: return Response({'code': 400, 'msg': err}) @@ -2880,11 +2880,17 @@ class AddMemberView(APIView): if '3300a' not in permissions: 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( - request, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, - ) + if resolve_club_scope(request) != DATA_SCOPE_ALL and '000001' not in permissions: + return Response({ + 'code': 403, + 'msg': '仅集团汇总视图可新建全局会员类型;俱乐部请从会员 catalog 上架', + }) + + data, err = add_global_member(jieshao, jiage, guanshifc, zuzhangfc, jtjieshao) if err: return Response({'code': 400, 'msg': err}) 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): """ diff --git a/jituan/management/commands/create_club.py b/jituan/management/commands/create_club.py index 09df976..3b7d812 100644 --- a/jituan/management/commands/create_club.py +++ b/jituan/management/commands/create_club.py @@ -72,6 +72,12 @@ class Command(BaseCommand): jiage=row.jiage or Decimal('0'), guanshifc=row.guanshifc 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, bankuai_id=row.bankuai_id, ) diff --git a/jituan/migrations/0006_club_huiyuan_trial_fields.py b/jituan/migrations/0006_club_huiyuan_trial_fields.py new file mode 100644 index 0000000..1e6ad75 --- /dev/null +++ b/jituan/migrations/0006_club_huiyuan_trial_fields.py @@ -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='体验组长分成', + ), + ), + ] diff --git a/jituan/models.py b/jituan/models.py index a2ed461..fae4bbf 100644 --- a/jituan/models.py +++ b/jituan/models.py @@ -99,6 +99,18 @@ class ClubHuiyuanPrice(QModel): 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')) 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) bankuai_id = models.IntegerField(null=True, blank=True) CreateTime = models.DateTimeField(auto_now_add=True) diff --git a/jituan/services/club_member_admin.py b/jituan/services/club_member_admin.py index 14d7dfb..c3764ea 100644 --- a/jituan/services/club_member_admin.py +++ b/jituan/services/club_member_admin.py @@ -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 上架' diff --git a/jituan/services/member_recharge.py b/jituan/services/member_recharge.py index e620e78..b7ab539 100644 --- a/jituan/services/member_recharge.py +++ b/jituan/services/member_recharge.py @@ -1,12 +1,14 @@ """ 会员微信充值履约(回调 / 前端确认共用,幂等)。 -分红策略(定稿): -- 第几次购买 cishu:本俱乐部该打手该会员,已支付成功 czjilu 笔数(不含本单)+ 1。 - 与换管事无关,不看 gsfenhong,不看管事被分了几次。 -- cishu=1:先 duoci_fenhong(club_id+受益人+会员+cishu);无则 club_huiyuan_price 默认分成。 -- cishu>=2:仅 duoci_fenhong;无配置则不分(不读管事/组长用户表二次或永久分红字段)。 -- 价格与默认分成:仅 club_huiyuan_price + order.club_id,不读全局 huiyuan 表。 +分红策略(定稿 v2): +- formal_cishu:本俱乐部 + 打手 + 会员,czjilu 已支付正式单(is_trial=false)笔数 + 1。 + 与换管事无关;体验单不计入。 +- formal_cishu=1:duoci_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_fenhong;cishu=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 diff --git a/jituan/urls.py b/jituan/urls.py index 4345aa1..40b9897 100644 --- a/jituan/urls.py +++ b/jituan/urls.py @@ -2,6 +2,9 @@ from django.urls import path from backend.view import ( AddMemberView, + ClubMemberCatalogView, + ClubMemberDisableView, + ClubMemberEnableView, FaKuanChuLiView, FaKuanChuangJianView, FaKuanLieBiaoView, @@ -96,5 +99,8 @@ urlpatterns = [ path('houtai/hthqhylb', GetMemberListView.as_view(), name='jituan_member_list'), path('houtai/htxghyxx', UpdateMemberView.as_view(), name='jituan_member_update'), 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'), ] diff --git a/products/migrations/0006_member_trial_fields.py b/products/migrations/0006_member_trial_fields.py new file mode 100644 index 0000000..9fd8be6 --- /dev/null +++ b/products/migrations/0006_member_trial_fields.py @@ -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='是否体验会员分红'), + ), + ] diff --git a/products/models.py b/products/models.py index 45ea816..efce06d 100644 --- a/products/models.py +++ b/products/models.py @@ -172,6 +172,8 @@ class Huiyuangoumai(QModel): club_id = models.CharField( 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: db_table = 'huiyuangoumai' @@ -218,6 +220,10 @@ class Czjilu(QModel): club_id = models.CharField( 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='创建时间') UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间') class Meta: @@ -258,6 +264,7 @@ class Gsfenhong(QModel): club_id = models.CharField( max_length=16, default='xq', db_index=True, verbose_name='所属俱乐部', ) + is_trial = models.BooleanField(default=False, verbose_name='是否体验会员分红') class Meta: db_table = 'gsfenhong' diff --git a/products/views.py b/products/views.py index 556c100..1d504a7 100644 --- a/products/views.py +++ b/products/views.py @@ -267,7 +267,7 @@ class DashouHuiyuanList(APIView): }, status=status.HTTP_400_BAD_REQUEST) # 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) huiyuan_queryset = Huiyuan.query.all().only( @@ -275,15 +275,27 @@ class DashouHuiyuanList(APIView): ).order_by('jiage') huiyuan_list = [] + yonghuid = request.user.UserUID 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: 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({ 'id': huiyuan.huiyuan_id, 'mingzi': huiyuan.jieshao, 'jiage': float(club_price), + 'formal_days': formal_days, '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. 返回数据 @@ -1381,6 +1393,7 @@ class HuiyuanGoumai(APIView): try: # 1. 获取会员ID参数 huiyuanid = request.data.get('huiyuanid') + is_trial = bool(request.data.get('is_trial') or request.data.get('isTrial')) if not huiyuanid: return Response({ 'code': 400, @@ -1413,12 +1426,15 @@ class HuiyuanGoumai(APIView): }, status=status.HTTP_400_BAD_REQUEST) club_id = resolve_effective_club_id(request, request.user) - from jituan.services.member_recharge import club_huiyuan_sellable - sellable, club_price = club_huiyuan_sellable(club_id, huiyuanid) - if not sellable: + from jituan.services.member_recharge import validate_member_purchase + + ok, err_msg, club_price, purchase_days = validate_member_purchase( + request.user.UserUID, huiyuanid, club_id, is_trial, + ) + if not ok: return Response({ 'code': 400, - 'message': '该俱乐部未配置此会员价格,请联系管理员' + 'message': err_msg or '不可购买', }, status=status.HTTP_400_BAD_REQUEST) # 5. 生成订单ID(前缀CzHY表示会员充值) @@ -1442,11 +1458,14 @@ class HuiyuanGoumai(APIView): shuoming=shuoming, huiyuan_id=huiyuanid, # 存储会员ID club_id=club_id, + is_trial=is_trial, + purchase_days=int(purchase_days), ) logger.info( f"创建会员充值订单: 用户{request.user.UserUID}, 订单{dingdanid}, " - f"会员{huiyuanid}, 俱乐部{club_id}, 金额{jine}元") + f"会员{huiyuanid}, 俱乐部{club_id}, 体验={is_trial}, " + f"金额{jine}元, 天数{purchase_days}") # 7. 生成微信支付参数 try: @@ -1477,6 +1496,8 @@ class HuiyuanGoumai(APIView): 'dingdanid': dingdanid, 'club_id': club_id, 'jine': jine, + 'is_trial': is_trial, + 'purchase_days': purchase_days, }, status=status.HTTP_200_OK) except Exception as e: @@ -5862,8 +5883,11 @@ class DsqrgmdhView(APIView): 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) - if leixing == 1 and not huiyuan_id: - return Response({'code': 400, 'msg': '购买会员时必须提供 huiyuan_id'}, status=status.HTTP_400_BAD_REQUEST) + if leixing == 1: + return Response({ + 'code': 400, + 'msg': '会员购买仅支持微信支付,请使用会员充值页面', + }, status=status.HTTP_400_BAD_REQUEST) if leixing == 2: if not yajin_jine: return Response({'code': 400, 'msg': '购买押金时必须提供 yajin_jine'}, status=status.HTTP_400_BAD_REQUEST) diff --git a/users/views.py b/users/views.py index 5129965..9fe25ac 100644 --- a/users/views.py +++ b/users/views.py @@ -2024,6 +2024,10 @@ class TixianShenqingView(APIView): if dashou.yue < jine: 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 ''}} def _check_guanshi_dividend_withdraw(self, user_main, jine): @@ -8893,35 +8897,27 @@ class KefuUpdateDashouView(APIView): return Response({'code': 403, 'msg': '无权限给打手加会员'}) if not huiyuan_id: return Response({'code': 400, 'msg': '缺少会员ID'}) - days = int(value) if value else 30 # 默认加30天 try: huiyuan = Huiyuan.query.get(huiyuan_id=huiyuan_id) except Huiyuan.DoesNotExist: return Response({'code': 404, 'msg': '会员类型不存在'}) - # 获取或创建购买记录 - record, created = Huiyuangoumai.objects.select_for_update().get_or_create( - yonghu_id=dashou_id, - huiyuan_id=huiyuan_id, - defaults={ - 'huiyuan_zhuangtai': 1, - 'jieshao': huiyuan.jieshao, - 'daoqi_time': timezone.now() + timedelta(days=days), - } + 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) + row = get_club_huiyuan_row(player_club, huiyuan_id, require_enabled=False) + days = int(value) if value else int(row.formal_days or 30) if row else 30 + record, err = extend_member_entitlement_admin( + dashou_id, huiyuan_id, days, player_club, ) - if not created: - # 延长有效期 - new_daoqi = record.daoqi_time + timedelta(days=days) - record.daoqi_time = new_daoqi - record.huiyuan_zhuangtai = 1 - record.save() - # 记录修改日志 + if err: + return Response({'code': 400, 'msg': err}) Xiugaijilu.query.create( yonghuid=dashou_id, xiugaiid=kefu.user.Phone, leixing=2, huiyuants=days, 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')}})