diff --git a/a_long_dianjing/settings.py b/a_long_dianjing/settings.py index 275571d..7a00e27 100644 --- a/a_long_dianjing/settings.py +++ b/a_long_dianjing/settings.py @@ -41,12 +41,14 @@ INSTALLED_APPS = [ 'shop', 'rank', 'merchant_ops', + 'jituan', ] MIDDLEWARE = [ 'utils.ip_filter_middleware.IPFilterMiddleware', 'utils.debug_middleware.DebugExceptionMiddleware', 'corsheaders.middleware.CorsMiddleware', + 'jituan.middleware.ClubContextMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -222,6 +224,7 @@ CORS_ALLOW_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") CORS_ALLOW_HEADERS = [ "accept", "accept-encoding", "authorization", "content-type", "dnt", "origin", "user-agent", "x-csrftoken", "x-requested-with", + "x-club-id", "x-club-scope", ] # ==================== IP 访问控制 ==================== diff --git a/a_long_dianjing/urls.py b/a_long_dianjing/urls.py index d0c2f9e..0646203 100644 --- a/a_long_dianjing/urls.py +++ b/a_long_dianjing/urls.py @@ -24,6 +24,7 @@ urlpatterns = [ path('houtai/', include('backend.urls')), path('shangdian/', include('shop.urls')), path('dengji/', include('rank.urls')), + path('jituan/', include('jituan.urls')), ] # 重要说明: diff --git a/backend/migrations/0003_withdrawaldailystats_club_id.py b/backend/migrations/0003_withdrawaldailystats_club_id.py new file mode 100644 index 0000000..f4c0a7f --- /dev/null +++ b/backend/migrations/0003_withdrawaldailystats_club_id.py @@ -0,0 +1,28 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backend', '0002_rename_abnormal_us_create__ee16da_idx_abnormal_us_createt_38f96c_idx'), + ] + + operations = [ + migrations.AddField( + model_name='withdrawaldailystats', + name='club_id', + field=models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID'), + ), + migrations.AlterUniqueTogether( + name='withdrawaldailystats', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='withdrawaldailystats', + unique_together={('club_id', 'Date', 'WithdrawalType')}, + ), + migrations.AddIndex( + model_name='withdrawaldailystats', + index=models.Index(fields=['club_id', 'Date', 'WithdrawalType'], name='tixian_ri_club_date_type_idx'), + ), + ] diff --git a/backend/migrations/0004_role_daily_club_id.py b/backend/migrations/0004_role_daily_club_id.py new file mode 100644 index 0000000..ccf689e --- /dev/null +++ b/backend/migrations/0004_role_daily_club_id.py @@ -0,0 +1,56 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backend', '0003_withdrawaldailystats_club_id'), + ] + + operations = [ + migrations.AddField( + model_name='merchantdailystats', + name='club_id', + field=models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID'), + ), + migrations.AddField( + model_name='playerdailystats', + name='club_id', + field=models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID'), + ), + migrations.AddField( + model_name='leaderdailystats', + name='club_id', + field=models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID'), + ), + migrations.AddField( + model_name='managerdailystats', + name='club_id', + field=models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID'), + ), + migrations.AddField( + model_name='managerrenewaldailystats', + name='club_id', + field=models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID'), + ), + migrations.AlterUniqueTogether( + name='merchantdailystats', + unique_together={('club_id', 'MerchantID', 'Date')}, + ), + migrations.AlterUniqueTogether( + name='playerdailystats', + unique_together={('club_id', 'PlayerID', 'Date')}, + ), + migrations.AlterUniqueTogether( + name='leaderdailystats', + unique_together={('club_id', 'LeaderID', 'Date')}, + ), + migrations.AlterUniqueTogether( + name='managerdailystats', + unique_together={('club_id', 'ManagerID', 'Date')}, + ), + migrations.AlterUniqueTogether( + name='managerrenewaldailystats', + unique_together={('club_id', 'ManagerID', 'Date')}, + ), + ] diff --git a/backend/models.py b/backend/models.py index ed1b591..14695be 100644 --- a/backend/models.py +++ b/backend/models.py @@ -122,6 +122,7 @@ class AbnormalUserLog(QModel): # ==================== 6. 商家每日统计 ==================== class MerchantDailyStats(QModel): """商家每日统计表(原 ShangjiaRiTongji)""" + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID') MerchantID = models.CharField( max_length=7, db_index=True, db_column='yonghuid', verbose_name='商家用户ID') Date = models.DateField( @@ -151,7 +152,7 @@ class MerchantDailyStats(QModel): class Meta: db_table = 'shangjia_ri_tongji' verbose_name = '商家每日统计' - unique_together = [['MerchantID', 'Date']] + unique_together = [['club_id', 'MerchantID', 'Date']] indexes = [ models.Index(fields=['Date', '-AssignedAmount']), models.Index(fields=['Date', '-SettledAmount']), @@ -165,6 +166,7 @@ class MerchantDailyStats(QModel): # ==================== 7. 打手每日统计 ==================== class PlayerDailyStats(QModel): """打手每日统计表(原 DashouRiTongji)""" + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID') PlayerID = models.CharField( max_length=7, db_index=True, db_column='yonghuid', verbose_name='打手用户ID') Date = models.DateField( @@ -194,7 +196,7 @@ class PlayerDailyStats(QModel): class Meta: db_table = 'dashou_ri_tongji' verbose_name = '打手每日统计' - unique_together = [['PlayerID', 'Date']] + unique_together = [['club_id', 'PlayerID', 'Date']] indexes = [ models.Index(fields=['Date', '-AcceptedOrderTotal']), models.Index(fields=['Date', '-CompletedOrderTotal']), @@ -209,6 +211,7 @@ class PlayerDailyStats(QModel): # ==================== 8. 组长每日统计 ==================== class LeaderDailyStats(QModel): """组长每日统计表(原 ZuzhangRiTongji)""" + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID') LeaderID = models.CharField( max_length=7, db_index=True, db_column='yonghuid', verbose_name='组长用户ID') Date = models.DateField( @@ -226,7 +229,7 @@ class LeaderDailyStats(QModel): class Meta: db_table = 'zuzhang_ri_tongji' verbose_name = '组长每日统计' - unique_together = [['LeaderID', 'Date']] + unique_together = [['club_id', 'LeaderID', 'Date']] indexes = [ models.Index(fields=['Date', '-InvitedManagerCount']), models.Index(fields=['Date', '-TotalIncome']), @@ -239,6 +242,7 @@ class LeaderDailyStats(QModel): # ==================== 9. 管事每日统计 ==================== class ManagerDailyStats(QModel): """管事每日统计表(原 GuanshiRiTongji)""" + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID') ManagerID = models.CharField( max_length=7, db_index=True, db_column='yonghuid', verbose_name='管事用户ID') Date = models.DateField( @@ -255,7 +259,7 @@ class ManagerDailyStats(QModel): class Meta: db_table = 'guanshi_ri_tongji' verbose_name = '管事每日统计' - unique_together = [['ManagerID', 'Date']] + unique_together = [['club_id', 'ManagerID', 'Date']] indexes = [ models.Index(fields=['Date', '-InvitedPlayerCount']), models.Index(fields=['Date', '-RechargedPlayerCount']), @@ -269,6 +273,7 @@ class ManagerDailyStats(QModel): # ==================== 10. 管事续费每日统计 ==================== class ManagerRenewalDailyStats(QModel): """管事续费每日统计表(原 GuanshiXufeiRiTongji)""" + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID') ManagerID = models.CharField( max_length=7, db_index=True, db_column='yonghuid', verbose_name='管事用户ID') Date = models.DateField( @@ -283,7 +288,7 @@ class ManagerRenewalDailyStats(QModel): class Meta: db_table = 'guanshi_xufei_ri_tongji' verbose_name = '管事续费每日统计' - unique_together = [['ManagerID', 'Date']] + unique_together = [['club_id', 'ManagerID', 'Date']] indexes = [ models.Index(fields=['Date', '-RenewalTotal']), models.Index(fields=['Date', '-RenewalRevenue']), @@ -295,7 +300,8 @@ class ManagerRenewalDailyStats(QModel): # ==================== 11. 提现每日统计 ==================== class WithdrawalDailyStats(QModel): - """每日提现统计表(原 TixianRiTongji),按身份统计当天提现总额""" + """每日提现统计表(原 TixianRiTongji),按俱乐部+身份统计当天提现到账总额""" + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID') Date = models.DateField( db_index=True, db_column='riqi', verbose_name='日期') WithdrawalType = models.IntegerField( @@ -308,8 +314,11 @@ class WithdrawalDailyStats(QModel): class Meta: db_table = 'tixian_ri_tongji' verbose_name = '每日提现统计' - unique_together = [['Date', 'WithdrawalType']] - indexes = [models.Index(fields=['Date', 'WithdrawalType'])] + unique_together = [['club_id', 'Date', 'WithdrawalType']] + indexes = [ + models.Index(fields=['club_id', 'Date', 'WithdrawalType']), + models.Index(fields=['Date', 'WithdrawalType']), + ] def __str__(self): return f"{self.Date} - {self.get_WithdrawalType_display()} 提现总额: {self.total_amount}" diff --git a/backend/serializers.py b/backend/serializers.py index 8089310..274dd7f 100644 --- a/backend/serializers.py +++ b/backend/serializers.py @@ -32,7 +32,7 @@ class PopupPageSerializer(serializers.ModelSerializer): class Meta: model = PopupPage fields = [ - 'id', 'page_key', 'name', 'description', + 'id', 'club_id', 'page_key', 'name', 'description', 'is_active', 'ignore_user_mute', 'created_at', 'updated_at', 'popups' ] \ No newline at end of file diff --git a/backend/utils.py b/backend/utils.py index 44d18bf..b157bd0 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -21,6 +21,16 @@ from gvsdsdk.fluent import db, func, FQ logger = logging.getLogger(__name__) +def _club_id_for_yonghuid(yonghuid, club_id=None): + from jituan.constants import CLUB_ID_DEFAULT + from jituan.services.club_user import get_user_club_id + from users.business_models import User + if club_id: + return club_id + u = User.query.filter(UserUID=yonghuid).first() + return get_user_club_id(u) if u else CLUB_ID_DEFAULT + + def _get_real_ip(request): """获取真实IP(考虑代理)""" x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') @@ -150,26 +160,24 @@ def verify_kefu_permission(request, username_from_frontend=None): -def update_dashou_daily_by_action(yonghuid, amount, action): +def update_dashou_daily_by_action(yonghuid, amount, action, club_id=None): """ 根据行为类型更新打手每日统计(接单、成交、退款) - - 参数: - yonghuid: 打手用户ID - amount: 本单涉及的金额(Decimal) - action: 1 = 接单, 2 = 成交(结算), 3 = 退款 """ amount = Decimal(str(amount)) if amount else Decimal('0.00') if amount <= 0: - return # 金额非正不统计 + return + cid = _club_id_for_yonghuid(yonghuid, club_id) today = date.today() with transaction.atomic(): stat, created = PlayerDailyStats.objects.select_for_update().get_or_create( + club_id=cid, PlayerID=yonghuid, Date=today, defaults={ + 'club_id': cid, 'PlayerID': yonghuid, 'Date': today, 'AcceptedOrderTotal': 0, @@ -198,33 +206,24 @@ def update_dashou_daily_by_action(yonghuid, amount, action): PlayerDailyStats.query.filter(id=stat.id).update(**update_fields) -def update_shangjia_daily(yonghuid, amount, action, order_id=None): +def update_shangjia_daily(yonghuid, amount, action, order_id=None, club_id=None): """ 根据操作行为更新商家每日统计(派发、结算、退款) - - 参数: - yonghuid: 商家用户ID - amount: 当前订单涉及的金额(Decimal) - action: 操作类型,1 = 派发,2 = 结算(成交),3 = 退款 - order_id: 订单ID;传入时同步更新该订单派单归属客服的日统计 - - 说明: - - 金额必须大于0,否则不执行任何操作 - - 使用 select_for_update + F() 表达式保证原子性和并发安全 - - 每天每个商家只会有一条统计记录,不存在则自动创建 """ amount = Decimal(str(amount)) if amount else Decimal('0.00') if amount <= 0: - return # 金额非正,不统计 + return + cid = _club_id_for_yonghuid(yonghuid, club_id) today = date.today() with transaction.atomic(): - # 获取或创建当天统计记录,同时加锁防止并发 stat, created = MerchantDailyStats.objects.select_for_update().get_or_create( + club_id=cid, MerchantID=yonghuid, Date=today, defaults={ + 'club_id': cid, 'MerchantID': yonghuid, 'Date': today, 'AssignedOrderCount': 0, @@ -262,18 +261,20 @@ def update_shangjia_daily(yonghuid, amount, action, order_id=None): logger.warning('同步客服日统计失败 order_id=%s', order_id, exc_info=True) -def update_guanshi_daily_by_action(yonghuid, action, amount=Decimal('0.00')): +def update_guanshi_daily_by_action(yonghuid, action, amount=Decimal('0.00'), club_id=None): """ 根据操作行为更新管事每日统计(邀请打手、充值会员、订单/押金分红) - action: 1 = 邀请打手, 2 = 充值会员, 3 = 商家订单分红, 4 = 押金分红 """ + cid = _club_id_for_yonghuid(yonghuid, club_id) today = date.today() with transaction.atomic(): stat, created = ManagerDailyStats.objects.select_for_update().get_or_create( + club_id=cid, ManagerID=yonghuid, Date=today, defaults={ + 'club_id': cid, 'ManagerID': yonghuid, 'Date': today, 'InvitedPlayerCount': 0, @@ -319,17 +320,17 @@ def update_guanshi_daily_by_action(yonghuid, action, amount=Decimal('0.00')): ) -def update_guanshi_xufei_daily(yonghuid, xufei_jine=Decimal('0.00')): - """ - 管事续费统计(单独表) - 用法: update_guanshi_xufei_daily('300001', Decimal('30')) - """ +def update_guanshi_xufei_daily(yonghuid, xufei_jine=Decimal('0.00'), club_id=None): + """管事续费统计(单独表)""" + cid = _club_id_for_yonghuid(yonghuid, club_id) today = date.today() with transaction.atomic(): stat, created = ManagerRenewalDailyStats.objects.select_for_update().get_or_create( + club_id=cid, ManagerID=yonghuid, Date=today, defaults={ + 'club_id': cid, 'ManagerID': yonghuid, 'Date': today, 'RenewalTotal': 1, @@ -343,24 +344,18 @@ def update_guanshi_xufei_daily(yonghuid, xufei_jine=Decimal('0.00')): ) -def update_zuzhang_daily_by_action(yonghuid, action, amount=Decimal('0.00')): - """ - 根据操作行为更新组长每日统计(邀请管事、分佣收入) - - 参数: - yonghuid: 组长用户ID - action: 操作类型 - 1 = 邀请管事(次数+1,无金额) - 2 = 分佣收入(累加金额) - amount: 分佣金额(Decimal),仅在 action=2 时有效 - """ +def update_zuzhang_daily_by_action(yonghuid, action, amount=Decimal('0.00'), club_id=None): + """根据操作行为更新组长每日统计""" + cid = _club_id_for_yonghuid(yonghuid, club_id) today = date.today() with transaction.atomic(): stat, created = LeaderDailyStats.objects.select_for_update().get_or_create( + club_id=cid, LeaderID=yonghuid, Date=today, defaults={ + 'club_id': cid, 'LeaderID': yonghuid, 'Date': today, 'InvitedManagerCount': 0, @@ -394,23 +389,22 @@ def update_zuzhang_daily_by_action(yonghuid, action, amount=Decimal('0.00')): -def update_tixian_daily_stat(leixing, amount): +def update_tixian_daily_stat(leixing, amount, club_id=None): """ - 更新每日提现统计(平台维度) + 更新每日提现统计(平台维度,按俱乐部) leixing: 1=打手, 2=管事, 3=组长 amount: 本次提现金额(申请金额,含手续费) """ + from jituan.constants import CLUB_ID_DEFAULT + from jituan.services.withdraw_config import get_or_create_platform_daily_stat + + club_id = (club_id or '').strip() or CLUB_ID_DEFAULT today = date.today() with transaction.atomic(): - stat, created = WithdrawalDailyStats.objects.select_for_update().get_or_create( - Date=today, - WithdrawalType=leixing, - defaults={'total_amount': amount} + stat = get_or_create_platform_daily_stat(club_id, leixing, today) + WithdrawalDailyStats.query.filter(id=stat.id).update( + total_amount=F('total_amount') + amount ) - if not created: - WithdrawalDailyStats.query.filter(id=stat.id).update( - total_amount=F('total_amount') + amount - ) # 罚款权限(与 permission 表一致) @@ -561,13 +555,14 @@ XIUGAI_LEIXING_LABEL = { } -def write_xiugai_log(*, yonghuid, xiugaiid, leixing, qitashuoming, **extra_fields): +def write_xiugai_log(*, yonghuid, xiugaiid, leixing, qitashuoming, club_id=None, request_ip='', **extra_fields): """ 统一写入 xiugaijilu 修改记录。 qitashuoming 应详细描述本次操作(尤其是金额、身份、上下级变更等)。 extra_fields 可传入 xiugaitijiao、shangjiayue、guanshiyue 等模型字段。 """ from users.models import Xiugaijilu + from jituan.services.admin_audit import log_admin_audit_from_xiugai if not qitashuoming: label = XIUGAI_LEIXING_LABEL.get(leixing, f'类型{leixing}') @@ -579,4 +574,12 @@ def write_xiugai_log(*, yonghuid, xiugaiid, leixing, qitashuoming, **extra_field leixing=leixing, qitashuoming=qitashuoming, **extra_fields, + ) + log_admin_audit_from_xiugai( + yonghuid=yonghuid, + xiugaiid=xiugaiid, + leixing=leixing, + qitashuoming=qitashuoming, + club_id=club_id, + request_ip=request_ip, ) \ No newline at end of file diff --git a/backend/view.py b/backend/view.py index 4e134a8..af6eba7 100644 --- a/backend/view.py +++ b/backend/view.py @@ -38,7 +38,12 @@ from backend.utils import ( update_dashou_daily_by_action, update_shangjia_daily ) -from orders.utils import update_daily_payout +from jituan.services.club_context import filter_queryset_by_club, filter_user_related_by_club, filter_user_qs_by_club, orders_for_request +from jituan.services.club_penalty import ( + filter_penalty_qs, resolve_penalty_club_id, filter_gsfenhong_qs, + forbid_penalty_out_of_scope, resolve_penalty_record_club_id, +) +from jituan.services.club_user_access import forbid_if_user_out_of_scope, list_response_meta from shop.utils import update_dianpu_daily_stat from products.utils import update_shangpin_daily_stat from orders.notice_tasks import dingdan_guangbo @@ -743,7 +748,9 @@ class KefuGetDashouListView(APIView): return Response({'code': 403, 'msg': '您没有权限查看打手列表'}) # 4. 构建打手查询集(仅打手类型) - dashou_qs = UserDashou.query.select_related('user').all() + dashou_qs = filter_user_related_by_club( + UserDashou.query.select_related('user').all(), request, + ) # 关键词筛选(用户ID或昵称) if keyword: @@ -758,12 +765,12 @@ class KefuGetDashouListView(APIView): # 6. 统计有效打手数(有会员且未过期) dashou_user_ids = dashou_qs.values_list('user__UserUID', flat=True) now = timezone.now() - valid_user_ids = Huiyuangoumai.query.filter( + hy_qs = filter_queryset_by_club(Huiyuangoumai.query.all(), request) + valid_dashou_count = hy_qs.filter( yonghu_id__in=dashou_user_ids, huiyuan_zhuangtai=1, - daoqi_time__gt=now - ).values_list('yonghu_id', flat=True).distinct() - valid_dashou_count = valid_user_ids.count() + daoqi_time__gt=now, + ).values('yonghu_id').distinct().count() # 7. 分页 offset = (page - 1) * page_size @@ -784,8 +791,11 @@ class KefuGetDashouListView(APIView): has_more = (offset + len(dashou_list)) < total_dashou + meta = list_response_meta(request) return Response({ 'code': 0, + 'club_id': meta['club_id'], + 'scope': meta['scope'], 'data': { 'list': result, 'has_more': has_more, @@ -836,6 +846,10 @@ class KefuGetDashouDetailView(APIView): except User.DoesNotExist: return Response({'code': 404, 'msg': '打手不存在'}) + denied = forbid_if_user_out_of_scope(request, user_main) + if denied: + return denied + try: dashou = user_main.DashouProfile except ObjectDoesNotExist: @@ -1346,7 +1360,9 @@ class KefuGetShangjiaListView(APIView): return Response({'code': 403, 'msg': '您没有权限查看商家列表'}) # 4. 构建商家查询集(商家扩展表 + 关联用户主表) - shangjia_qs = UserShangjia.query.select_related('user').all() + shangjia_qs = filter_user_related_by_club( + UserShangjia.query.select_related('user').all(), request, + ) # 关键词筛选(用户ID或昵称) if keyword: @@ -1393,6 +1409,7 @@ class KefuGetShangjiaListView(APIView): # 5. 统计总商家数(应用筛选后) total = shangjia_qs.count() + valid_merchant_count = shangjia_qs.filter(zhuangtai=1).count() # 6. 分页 offset = (page - 1) * page_size @@ -1412,11 +1429,15 @@ class KefuGetShangjiaListView(APIView): 'zhuangtai': shangjia.zhuangtai, # 1=正常,2=禁用 }) + meta = list_response_meta(request) return Response({ 'code': 0, + 'club_id': meta['club_id'], + 'scope': meta['scope'], 'data': { 'list': result, 'total': total, + 'valid_merchant_count': valid_merchant_count, } }) @@ -1466,6 +1487,10 @@ class KefuGetShangjiaDetailView(APIView): except UserShangjia.DoesNotExist: return Response({'code': 404, 'msg': '商家不存在'}) + denied = forbid_if_user_out_of_scope(request, shangjia.user) + if denied: + return denied + user = shangjia.user data = { @@ -2847,9 +2872,12 @@ class GetGuanliListView(APIView): status = request.data.get('status') # 构建查询:从 User 开始,关联 UserGuanshi 和 UserBoss(左连接获取昵称) - qs = User.query.filter( - GuanshiProfile__isnull=False # 确保是管事 - ).select_related('GuanshiProfile').select_related('BossProfile') + qs = filter_user_qs_by_club( + User.query.filter( + GuanshiProfile__isnull=False # 确保是管事 + ).select_related('GuanshiProfile').select_related('BossProfile'), + request, + ) # 关键词搜索(用户ID 或 昵称来自 boss_profile.nickname) if keyword: @@ -2929,7 +2957,11 @@ class GetGuanliListView(APIView): 'chongzhifenrun': str(guanshi.chongzhifenrun), }) - return Response({'code': 0, 'data': {'list': result, 'total': total}}) + return Response({ + 'code': 0, + **list_response_meta(request), + 'data': {'list': result, 'total': total}, + }) @@ -2984,6 +3016,10 @@ class GetGuanliDetailView(APIView): except UserGuanshi.DoesNotExist: return Response({'code': 404, 'msg': '用户不是管事'}) + denied = forbid_if_user_out_of_scope(request, user) + if denied: + return denied + # 基础信息 is_zuzhang = UserZuzhang.query.filter(user=user).exists() base_data = { @@ -3377,8 +3413,12 @@ class UpdateGuanliView(APIView): # 4.2 首次定制分红(cishu=1) if custom_first is not None and isinstance(custom_first, dict): + from jituan.services.club_penalty import resolve_gsfenhong_club_id + guanshi_club = resolve_gsfenhong_club_id(dashouid=yonghuid) # 获取当前所有首次定制记录 - existing_first = DuociFenhong.query.filter(yonghuid=yonghuid, cishu=1) + existing_first = DuociFenhong.query.filter( + club_id=guanshi_club, yonghuid=yonghuid, cishu=1, + ) # 前端传来的定制字典 {会员ID: 金额} for huiyuan_id, amount in custom_first.items(): try: @@ -3386,17 +3426,19 @@ class UpdateGuanliView(APIView): except: continue if amount <= 0: - # 金额为0或负数表示删除定制 - DuociFenhong.query.filter(yonghuid=yonghuid, huiyuan=huiyuan_id, cishu=1).delete() + DuociFenhong.query.filter( + club_id=guanshi_club, yonghuid=yonghuid, huiyuan=huiyuan_id, cishu=1, + ).delete() else: DuociFenhong.query.update_or_create( + club_id=guanshi_club, yonghuid=yonghuid, huiyuan=huiyuan_id, cishu=1, defaults={ 'guanshi_fenhong': amount, - 'zuzhang_fenhong': Decimal('0') - } + 'zuzhang_fenhong': Decimal('0'), + }, ) # 删除那些在前端字典中不存在的首次定制记录(即已取消定制) for record in existing_first: @@ -3405,6 +3447,8 @@ class UpdateGuanliView(APIView): # 4.3 额外次数分红(cishu >= 2) if extra_map is not None and isinstance(extra_map, dict): + from jituan.services.club_penalty import resolve_gsfenhong_club_id + guanshi_club = resolve_gsfenhong_club_id(dashouid=yonghuid) # 收集需要保留的记录 keep_set = set() for times_str, members in extra_map.items(): @@ -3429,6 +3473,7 @@ class UpdateGuanliView(APIView): keep_set.add((cishu, huiyuan_id)) # 更新或创建 DuociFenhong.query.update_or_create( + club_id=guanshi_club, yonghuid=yonghuid, huiyuan=huiyuan_id, cishu=cishu, @@ -3438,7 +3483,9 @@ class UpdateGuanliView(APIView): } ) # 删除不在 keep_set 中的额外次数记录 - existing_extra = DuociFenhong.query.filter(yonghuid=yonghuid, cishu__gte=2) + existing_extra = DuociFenhong.query.filter( + club_id=guanshi_club, yonghuid=yonghuid, cishu__gte=2, + ) for record in existing_extra: if (record.cishu, record.huiyuan) not in keep_set: record.delete() @@ -3493,53 +3540,8 @@ class GetWithdrawSettingsView(APIView): if not any(p in permissions for p in ('5500a', '5500b', '5500c')): return Response({'code': 403, 'msg': '无权限查看提现设置'}) - # 1. 手续费利率(CommissionRate:5打手/6管事/8组长/9审核官/10商家/11打手押金) - rate_map = {} - for leixing, code in [(1, '5'), (2, '6'), (3, '8'), (4, '9'), (5, '11'), (6, '10')]: - obj = CommissionRate.query.filter(Platform=code).first() - rate_map[leixing] = float(obj.Rate) if obj and obj.Rate is not None else 0.0 - - # 1b. 订单/押金分红费率(CommissionRate:1平台订单/3商家订单/12押金管事/13商家订单管事分红) - order_rate_map = {} - for leixing, code in [(1, '1'), (3, '3'), (12, '12'), (13, '13')]: - obj = CommissionRate.query.filter(Platform=code).first() - order_rate_map[leixing] = float(obj.Rate) if obj and obj.Rate is not None else 0.0 - - # 2. 个人每日限额 (leixing=1,2,3) - quota_map = {} - for leixing in [1, 2, 3]: - obj = TixianQuotaDefault.query.filter(UserType=leixing).first() - quota_map[leixing] = float(obj.default_quota) if obj else 20.0 - - # 3. 每日总限额(提现类型1~6 → 配置表4~9) - total_limit_map = {} - for withdraw_leixing, quota_leixing in [(1, 4), (2, 5), (3, 6), (4, 7), (5, 8), (6, 9)]: - obj = TixianQuotaDefault.query.filter(UserType=quota_leixing).first() - total_limit_map[withdraw_leixing] = float(obj.default_quota) if obj else 0.0 - - # 4. 当日已提现总额(按提现类型 leixing 1~6) - today = date.today() - today_totals = {i: 0.0 for i in range(1, 7)} - records = WithdrawalDailyStats.query.filter(Date=today) - for rec in records: - if rec.WithdrawalType in today_totals: - today_totals[rec.WithdrawalType] = float(rec.total_amount) - - # 🆕 获取提现模式(自动1 / 手动2),默认2 - config = WithdrawConfig.query.filter(id=1).first() - withdraw_mode = config.mode if config else 2 - - return Response({ - 'code': 0, - 'data': { - 'withdraw_mode': withdraw_mode, - 'rates': rate_map, - 'order_rates': order_rate_map, - 'quotas': quota_map, - 'total_limits': total_limit_map, - 'today_totals': today_totals, - } - }) + from jituan.services.withdraw_config import build_withdraw_settings_payload + return Response({'code': 0, 'data': build_withdraw_settings_payload(request)}) class UpdateWithdrawSettingsView(APIView): @@ -3566,124 +3568,8 @@ class UpdateWithdrawSettingsView(APIView): # 权限映射:要修改的角色需要对应的权限 # 前端传来 rates, quotas, total_limits 三个对象,每个包含 1,2,3 - rates = request.data.get('rates', {}) - order_rates = request.data.get('order_rates', request.data.get('commission_rates', {})) - quotas = request.data.get('quotas', {}) - total_limits = request.data.get('total_limits', {}) - - # 角色类型映射 - role_perms = {1: '5500a', 2: '5500b', 3: '5500c'} - # 利率代码映射(1-3 有水缸限额;4审核官/5打手押金/6商家余额仅费率) - rate_code_map = {1: '5', 2: '6', 3: '8', 4: '9', 5: '11', 6: '10'} - # 订单/押金分红(1平台订单/3商家订单/12押金管事/13商家订单管事分红) - order_rate_code_map = {1: '1', 3: '3', 12: '12', 13: '13'} - # 总限额:提现类型 → TixianQuotaDefault.leixing(4~9) - total_code_map = {1: 4, 2: 5, 3: 6, 4: 7, 5: 8, 6: 9} - - def _rate_key_present(data, role): - return (str(role) in data and data[str(role)] is not None) or \ - (role in data and data[role] is not None) - - with transaction.atomic(): - # 遍历三个角色(限额相关) - for role in [1, 2, 3]: - perm_needed = role_perms[role] - if perm_needed not in permissions: - if _rate_key_present(rates, role) or \ - (str(role) in quotas and quotas[str(role)] is not None) or \ - (role in quotas and quotas.get(role) is not None) or \ - (str(role) in total_limits and total_limits[str(role)] is not None) or \ - (role in total_limits and total_limits.get(role) is not None): - return Response({'code': 403, 'msg': f'无权限修改{["","打手","管事","组长"][role]}相关设置(需要{perm_needed})'}) - - # 其他提现手续费(审核官/打手押金/商家余额):需 5500a/b/c 任一 - extra_role_names = {4: '考核官', 5: '打手押金', 6: '商家余额'} - for role in [4, 5, 6]: - if _rate_key_present(rates, role) and not any(p in permissions for p in ('5500a', '5500b', '5500c')): - return Response({'code': 403, 'msg': f'无权限修改{extra_role_names[role]}提现手续费率'}) - if (str(role) in total_limits and total_limits[str(role)] is not None) or \ - (role in total_limits and total_limits.get(role) is not None): - if not any(p in permissions for p in ('5500a', '5500b', '5500c')): - return Response({'code': 403, 'msg': f'无权限修改{extra_role_names[role]}总限额'}) - - # 订单/押金分红费率:需 5500a/b/c 任一 - for role in [1, 3, 12, 13]: - if _rate_key_present(order_rates, role) and not any(p in permissions for p in ('5500a', '5500b', '5500c')): - order_names = { - 1: '平台订单打手分红', 3: '商家订单打手分红', - 12: '押金管事分红', 13: '管事订单分红', - } - return Response({'code': 403, 'msg': f'无权限修改{order_names[role]}费率'}) - - # 1. 修改手续费利率 - for role in [1, 2, 3, 4, 5, 6]: - val = rates.get(str(role), rates.get(role)) - if val is not None: - rate = Decimal(str(val)) - code = rate_code_map[role] - obj, created = CommissionRate.objects.select_for_update().get_or_create( - Platform=code, - defaults={'Rate': rate} - ) - if not created: - obj.Rate = rate - obj.save() - - # 1b. 修改订单/押金分红费率 - for role in [1, 3, 12, 13]: - val = order_rates.get(str(role), order_rates.get(role)) - if val is not None: - rate = Decimal(str(val)) - code = order_rate_code_map[role] - obj, created = CommissionRate.objects.select_for_update().get_or_create( - Platform=code, - defaults={'Rate': rate} - ) - if not created: - obj.Rate = rate - obj.save() - - # 2. 修改个人每日限额 - for role in [1, 2, 3]: - if str(role) in quotas: - val = quotas[str(role)] - if val is not None: - quota = Decimal(str(val)) - obj, created = TixianQuotaDefault.objects.select_for_update().get_or_create( - UserType=role, - defaults={'default_quota': quota} - ) - if not created: - obj.default_quota = quota - obj.save() - - # 3. 修改每日总限额(1打手/2管事/3组长/4考核官/5押金/6商家) - for role in [1, 2, 3, 4, 5, 6]: - val = total_limits.get(str(role), total_limits.get(role)) - if val is not None: - limit = Decimal(str(val)) - code = total_code_map[role] - obj, created = TixianQuotaDefault.objects.select_for_update().get_or_create( - UserType=code, - defaults={'default_quota': limit} - ) - if not created: - obj.default_quota = limit - obj.save() - - # 🆕 修改提现模式(有5500a/b/c任一即可) - withdraw_mode = request.data.get('withdraw_mode') - if withdraw_mode is not None: - if not any(p in permissions for p in ('5500a', '5500b', '5500c')): - return Response({'code': 403, 'msg': '无权限修改提现模式'}) - # 更新或创建 id=1 的配置 - WithdrawConfig.query.update_or_create( - id=1, - defaults={'mode': int(withdraw_mode)} - ) - - # 注意:WithdrawalDailyStats 表由提现流程自动更新,此处不直接修改 - return Response({'code': 0, 'msg': '设置修改成功'}) + from jituan.services.withdraw_config import apply_withdraw_settings_update + return apply_withdraw_settings_update(request, permissions) @@ -3738,7 +3624,10 @@ class GetZuzhangListView(APIView): commission_value = request.data.get('commission_value') # 5. 构建基础 QuerySet(预加载关联表) - queryset = UserZuzhang.query.select_related('user', 'user__BossProfile').all() + queryset = filter_user_related_by_club( + UserZuzhang.query.select_related('user', 'user__BossProfile').all(), + request, + ) # 6. 应用筛选条件(使用 ORM 安全过滤) if keyword: @@ -3808,6 +3697,7 @@ class GetZuzhangListView(APIView): return Response({ 'code': 0, 'msg': 'success', + **list_response_meta(request), 'data': { 'list': data_list, 'total': total, @@ -3869,6 +3759,10 @@ class GetZuzhangDetailView(APIView): except UserZuzhang.DoesNotExist: return Response({'code': 404, 'msg': '用户不是组长'}) + denied = forbid_if_user_out_of_scope(request, user) + if denied: + return denied + # 基础信息 base_data = { 'yonghuid': user.UserUID, @@ -4134,7 +4028,11 @@ class UpdateZuzhangView(APIView): # 4.2 首次定制分红(cishu=1) if custom_first is not None and isinstance(custom_first, dict): - existing_first = DuociFenhong.query.filter(yonghuid=yonghuid, cishu=1) + from jituan.services.club_penalty import resolve_gsfenhong_club_id + zuzhang_club = resolve_gsfenhong_club_id(dashouid=yonghuid) + existing_first = DuociFenhong.query.filter( + club_id=zuzhang_club, yonghuid=yonghuid, cishu=1, + ) for huiyuan_id, amount in custom_first.items(): try: amount = Decimal(str(amount)) @@ -4142,9 +4040,12 @@ class UpdateZuzhangView(APIView): continue if amount <= 0: # 删除定制(恢复默认) - DuociFenhong.query.filter(yonghuid=yonghuid, huiyuan=huiyuan_id, cishu=1).delete() + DuociFenhong.query.filter( + club_id=zuzhang_club, yonghuid=yonghuid, huiyuan=huiyuan_id, cishu=1, + ).delete() else: DuociFenhong.query.update_or_create( + club_id=zuzhang_club, yonghuid=yonghuid, huiyuan=huiyuan_id, cishu=1, @@ -4160,6 +4061,8 @@ class UpdateZuzhangView(APIView): # 4.3 额外次数分红(cishu >= 2) if extra_map is not None and isinstance(extra_map, dict): + from jituan.services.club_penalty import resolve_gsfenhong_club_id + zuzhang_club = resolve_gsfenhong_club_id(dashouid=yonghuid) keep_set = set() for times_str, members in extra_map.items(): try: @@ -4182,6 +4085,7 @@ class UpdateZuzhangView(APIView): continue keep_set.add((cishu, huiyuan_id)) DuociFenhong.query.update_or_create( + club_id=zuzhang_club, yonghuid=yonghuid, huiyuan=huiyuan_id, cishu=cishu, @@ -4191,7 +4095,9 @@ class UpdateZuzhangView(APIView): } ) # 删除不在 keep_set 中的额外次数记录 - existing_extra = DuociFenhong.query.filter(yonghuid=yonghuid, cishu__gte=2) + existing_extra = DuociFenhong.query.filter( + club_id=zuzhang_club, yonghuid=yonghuid, cishu__gte=2, + ) for record in existing_extra: if (record.cishu, record.huiyuan) not in keep_set: record.delete() @@ -4367,6 +4273,8 @@ class GetOperationLogListView(APIView): page_size = 20 qs = Xiugaijilu.query.all().order_by('-CreateTime') + from jituan.services.admin_audit import filter_xiugaijilu_by_request + qs = filter_xiugaijilu_by_request(qs, request) if yonghuid: qs = qs.filter(yonghuid=yonghuid) if xiugaiid: @@ -4412,6 +4320,7 @@ class GetOperationLogListView(APIView): 'create_time': r.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if r.CreateTime else '', }) + from jituan.services.club_user_access import list_response_meta return Response({ 'code': 0, 'data': { @@ -4419,6 +4328,7 @@ class GetOperationLogListView(APIView): 'total': total, 'page': page, 'page_size': page_size, + **list_response_meta(request), }, }) @@ -4995,11 +4905,19 @@ class PopupNoticeListAPIView(APIView): return Response({'code': 403, 'msg': '您没有权限访问弹窗管理功能'}) # 4. 查询所有页面,并预加载关联的弹窗和图片(避免N+1查询) - pages = PopupPage.query.all().prefetch_related('popups__images').order_by('id') + from jituan.services.display_config import filter_popup_page_by_request, list_response_meta + + pages = filter_popup_page_by_request( + PopupPage.query.all().prefetch_related('popups__images'), + request, + ).order_by('id') # 5. 序列化输出 serializer = PopupPageSerializer(pages, many=True) - return Response({'code': 0, 'data': {'pages': serializer.data}}) + return Response({ + 'code': 0, + 'data': {'pages': serializer.data, **list_response_meta(request)}, + }) class PopupNoticeModifyAPIView(APIView): @@ -5020,6 +4938,11 @@ class PopupNoticeModifyAPIView(APIView): if REQUIRED_PERMISSION not in permissions: return Response({'code': 403, 'msg': '您没有权限操作弹窗管理'}) + from jituan.services.display_config import forbid_display_write_in_all_scope + deny = forbid_display_write_in_all_scope(request) + if deny: + return deny + # ---------- 2. 获取 action 并分发 ---------- action = request.data.get('action') if not action: @@ -5055,15 +4978,24 @@ class PopupNoticeModifyAPIView(APIView): def add_page(self, request): """添加新页面""" + from jituan.services.club_context import resolve_club_id_from_request + from jituan.services.display_config import forbid_display_write_in_all_scope + + deny = forbid_display_write_in_all_scope(request) + if deny: + return deny + data = request.data # 必填字段校验 if not data.get('page_key') or not data.get('name'): return Response({'code': 400, 'msg': '缺少 page_key 或 name'}) - # 唯一性校验 - if PopupPage.query.filter(page_key=data['page_key']).exists(): + club_id = resolve_club_id_from_request(request) + # 唯一性校验(同俱乐部内) + if PopupPage.query.filter(club_id=club_id, page_key=data['page_key']).exists(): return Response({'code': 400, 'msg': '页面标识已存在'}) page = PopupPage.query.create( + club_id=club_id, page_key=data['page_key'], name=data['name'], description=data.get('description', ''), @@ -5082,6 +5014,11 @@ class PopupNoticeModifyAPIView(APIView): except PopupPage.DoesNotExist: return Response({'code': 404, 'msg': '页面不存在'}) + from jituan.services.display_config import forbid_popup_page_out_of_scope + deny = forbid_popup_page_out_of_scope(request, page) + if deny: + return deny + # 更新允许修改的字段 page.page_key = request.data.get('page_key', page.page_key) page.name = request.data.get('name', page.name) @@ -5102,6 +5039,11 @@ class PopupNoticeModifyAPIView(APIView): except PopupPage.DoesNotExist: return Response({'code': 404, 'msg': '页面不存在'}) + from jituan.services.display_config import forbid_popup_page_out_of_scope, forbid_display_write_in_all_scope + deny = forbid_display_write_in_all_scope(request) or forbid_popup_page_out_of_scope(request, page) + if deny: + return deny + if cascade: # 级联删除:使用事务保证原子性 with transaction.atomic(): @@ -5135,6 +5077,11 @@ class PopupNoticeModifyAPIView(APIView): except PopupPage.DoesNotExist: return Response({'code': 404, 'msg': '页面不存在'}) + from jituan.services.display_config import forbid_popup_page_out_of_scope + deny = forbid_popup_page_out_of_scope(request, page) + if deny: + return deny + # 同一页面下 popup_id 必须唯一 if PopupConfig.query.filter(page=page, popup_id=popup_id).exists(): return Response({'code': 400, 'msg': '该页面下已存在相同的弹窗ID'}) @@ -5166,6 +5113,11 @@ class PopupNoticeModifyAPIView(APIView): except PopupConfig.DoesNotExist: return Response({'code': 404, 'msg': '弹窗不存在'}) + from jituan.services.display_config import forbid_popup_page_out_of_scope + deny = forbid_popup_page_out_of_scope(request, popup.page) + if deny: + return deny + # 更新弹窗字段 popup.popup_id = request.data.get('popup_id', popup.popup_id) popup.title = request.data.get('title', popup.title) @@ -5192,6 +5144,11 @@ class PopupNoticeModifyAPIView(APIView): except PopupConfig.DoesNotExist: return Response({'code': 404, 'msg': '弹窗不存在'}) + from jituan.services.display_config import forbid_popup_page_out_of_scope + deny = forbid_popup_page_out_of_scope(request, popup.page) + if deny: + return deny + with transaction.atomic(): for img in popup.images.all(): if img.image_url: @@ -5237,6 +5194,11 @@ class PopupNoticeModifyAPIView(APIView): except PopupConfig.DoesNotExist: return Response({'code': 404, 'msg': '弹窗不存在'}) + from jituan.services.display_config import forbid_popup_page_out_of_scope + deny = forbid_popup_page_out_of_scope(request, popup.page) + if deny: + return deny + # 获取上传的文件 file_obj = request.FILES.get('file') if not file_obj: @@ -5267,6 +5229,11 @@ class PopupNoticeModifyAPIView(APIView): except PopupImage.DoesNotExist: return Response({'code': 404, 'msg': '图片不存在'}) + from jituan.services.display_config import forbid_popup_page_out_of_scope + deny = forbid_popup_page_out_of_scope(request, image.popup_config.page) + if deny: + return deny + # 更新文字和排序 image.text = request.data.get('text', image.text) image.sort_order = request.data.get('sort_order', image.sort_order) @@ -5297,6 +5264,11 @@ class PopupNoticeModifyAPIView(APIView): except PopupImage.DoesNotExist: return Response({'code': 404, 'msg': '图片不存在'}) + from jituan.services.display_config import forbid_popup_page_out_of_scope + deny = forbid_popup_page_out_of_scope(request, image.popup_config.page) + if deny: + return deny + if image.image_url: delete_from_oss(image.image_url) image.delete() @@ -6191,7 +6163,7 @@ class FaKuanTongJiView(APIView): if not has_fadan_view_permission(permissions, username): return Response({'code': 403, 'msg': '无权限查看罚款统计'}) - stats = Penalty.query.aggregate( + stats = filter_penalty_qs(Penalty.query.all(), request).aggregate( total=Count('id'), daijiaona=Count('id', filter=Q(Status=1)), shensuzhong=Count('id', filter=Q(Status=3)), @@ -6199,7 +6171,8 @@ class FaKuanTongJiView(APIView): yibohui=Count('id', filter=Q(Status=4)), ) data = {k: v or 0 for k, v in stats.items()} - return Response({'code': 0, 'msg': '成功', 'data': data}) + from jituan.services.club_user_access import list_response_meta + return Response({'code': 0, 'msg': '成功', 'data': {**data, **list_response_meta(request)}}) except Exception as e: logger.error(f"罚款统计异常: {traceback.format_exc()}") return Response({'code': 500, 'msg': '系统繁忙'}) @@ -6230,7 +6203,7 @@ class FaKuanLieBiaoView(APIView): if page_size > 100: page_size = 100 - qs = Penalty.query.all() + qs = filter_penalty_qs(Penalty.query.all(), request) # 状态筛选 zhuangtai = request.data.get('zhuangtai') @@ -6309,6 +6282,7 @@ class FaKuanLieBiaoView(APIView): 'UpdateTime': r.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if r.UpdateTime else '', }) + from jituan.services.club_user_access import list_response_meta return Response({ 'code': 0, 'msg': '成功', 'data': { @@ -6316,6 +6290,7 @@ class FaKuanLieBiaoView(APIView): 'total': paginator.count, 'page': page, 'page_size': page_size, + **list_response_meta(request), } }) except Exception as e: @@ -6351,6 +6326,10 @@ class FaKuanChuLiView(APIView): except Penalty.DoesNotExist: return Response({'code': 404, 'msg': '罚单不存在'}) + deny = forbid_penalty_out_of_scope(request, penalty) + if deny: + return deny + if penalty.Status != 3: return Response({'code': 400, 'msg': '当前状态不可处理'}) @@ -6479,6 +6458,9 @@ class FaKuanChuangJianView(APIView): FineAmount=FineAmount, AffectsGrabbing=AffectsGrabbing, Status=1, # 待缴纳 + ClubID=resolve_penalty_club_id( + penalized_user_id=PenalizedUserID, request=request, + ), ) for relative_url in uploaded_urls: PenaltyAppealImage.query.create( @@ -6580,6 +6562,7 @@ class FineApplyView(APIView): Status=1, # 待缴纳 AffectsGrabbing=AffectsGrabbing, ApplicantIdentity=1, # 申请人身份:1 客服 + ClubID=resolve_penalty_club_id(order=order, request=request), ) for relative_url in uploaded_urls: PenaltyAppealImage.query.create( @@ -6658,6 +6641,7 @@ class PunishDashouView(APIView): ApplyStatus=0, DeductedPoints=jifen, OrderID=OrderID, + ClubID=resolve_penalty_record_club_id(order_id=OrderID, player_id=dashou_id), ) # 扣除打手积分 dashou.jifen = F('jifen') - jifen @@ -7158,7 +7142,9 @@ class HybkjtsjView(APIView): } # 充值分组查询 - chongzhi_qs = Czjilu.query.filter(**chongzhi_filter) \ + chongzhi_qs = filter_queryset_by_club( + Czjilu.query.filter(**chongzhi_filter), request, + ) \ .annotate(period=trunc_func) \ .values('period') \ .annotate( @@ -7174,7 +7160,9 @@ class HybkjtsjView(APIView): fenhong_agg['guanshi_amount'] = Sum('fenhong') if include_zuzhang: fenhong_agg['zuzhang_amount'] = Sum('zuzhang_fenhong') - fenhong_qs = Gsfenhong.query.filter(**fenhong_filter) \ + fenhong_qs = filter_gsfenhong_qs( + Gsfenhong.query.filter(**fenhong_filter), request, + ) \ .annotate(period=trunc_func) \ .values('period') \ .annotate(**fenhong_agg) \ @@ -7347,13 +7335,16 @@ class SzxxView(APIView): return Response({'code': 1, 'msg': '无效的颗粒度'}, status=400) # ---------- 收入分组查询 ---------- - income_qs = DailyIncomeStat.query.filter(**income_filter) \ - .values(group_field) \ + income_qs = filter_queryset_by_club( + DailyIncomeStat.query.filter(**income_filter), request, + ).values(group_field) \ .annotate(total_income=Sum('total_amount'), total_count=Sum('total_count')) \ .order_by(group_field) # ---------- 支出分组查询 ---------- - payout_qs = DailyPayoutStat.query.filter(**payout_filter) \ + payout_qs = filter_queryset_by_club( + DailyPayoutStat.query.filter(**payout_filter), request, + ).values(group_field) \ .values(group_field) \ .annotate(total_payout=Sum('total_amount'), total_count=Sum('total_count')) \ .order_by(group_field) @@ -7395,10 +7386,14 @@ class SzxxView(APIView): time_series.sort(key=lambda x: x['date']) # ---------- 汇总数据 ---------- - income_summary = DailyIncomeStat.query.filter(**summary_filter).aggregate( + income_summary = filter_queryset_by_club( + DailyIncomeStat.query.filter(**summary_filter), request, + ).aggregate( total_income=Sum('total_amount'), total_count=Sum('total_count') ) - payout_summary = DailyPayoutStat.query.filter(**summary_filter).aggregate( + payout_summary = filter_queryset_by_club( + DailyPayoutStat.query.filter(**summary_filter), request, + ).aggregate( total_payout=Sum('total_amount'), total_count=Sum('total_count') ) total_income = float(income_summary['total_income'] or 0) @@ -7542,7 +7537,10 @@ class CwhqjtddsjView(APIView): filters['Platform'] = order_source # ---------- 时间序列分组查询 ---------- - qs = Order.query.filter(**filters) \ + order_base = filter_queryset_by_club( + Order.query.filter(**filters), request, club_field='ClubID', + ) + qs = order_base \ .annotate(period=trunc_func) \ .values('period') \ .annotate( @@ -7595,7 +7593,9 @@ class CwhqjtddsjView(APIView): }) # ---------- 汇总数据 ---------- - summary_qs = Order.query.filter(**filters) + summary_qs = filter_queryset_by_club( + Order.query.filter(**filters), request, club_field='ClubID', + ) total_order_count = summary_qs.count() total_order_amount = summary_qs.aggregate(s=Sum('Amount'))['s'] or 0 total_success_count = summary_qs.filter(Status=3).count() @@ -7728,11 +7728,14 @@ class CwqtczhqView(APIView): # 1. 处理充值记录(Czjilu)—— 完全不变 cz_types = [t for t in types if t in [2, 3, 4, 5]] if cz_types: - cz_qs = Czjilu.query.filter( - CreateTime__gte=start_dt, - CreateTime__lt=end_dt, - leixing__in=cz_types, - zhuangtai=3 # 只统计支付成功的 + cz_qs = filter_queryset_by_club( + Czjilu.query.filter( + CreateTime__gte=start_dt, + CreateTime__lt=end_dt, + leixing__in=cz_types, + zhuangtai=3, + ), + request, ).annotate( period=trunc_func ).values('period', 'leixing').annotate( @@ -7762,10 +7765,13 @@ class CwqtczhqView(APIView): # 2. 处理罚款(Penalty)—— 修改:直接从 Penalty 表获取分红总额和分红笔数 if 6 in types: # 使用一次聚合查询,同时获取罚款笔数、罚款金额、分红总额、分红笔数 - penalty_qs = Penalty.query.filter( - CreateTime__gte=start_dt, - CreateTime__lt=end_dt, - Status=2 # 已缴纳 + penalty_qs = filter_penalty_qs( + Penalty.query.filter( + CreateTime__gte=start_dt, + CreateTime__lt=end_dt, + Status=2, + ), + request, ).annotate( period=trunc_func ).values('period').annotate( @@ -7816,7 +7822,9 @@ class CwqtczhqView(APIView): filter_kwargs['CreateTime__month'] = summary_q.month else: filter_kwargs['CreateTime__year'] = summary_q.year - agg = Czjilu.query.filter(**filter_kwargs).aggregate(count=Count('id'), amount=Sum('jine')) + agg = filter_queryset_by_club( + Czjilu.query.filter(**filter_kwargs), request, + ).aggregate(count=Count('id'), amount=Sum('jine')) count = agg['count'] or 0 amount = float(agg['amount'] or 0) profit = amount if lx == 3 else 0.0 @@ -7845,7 +7853,9 @@ class CwqtczhqView(APIView): penalty_filter['CreateTime__year'] = summary_q.year # 一次聚合:罚款笔数、金额、分红总额、分红笔数 - agg = Penalty.query.filter(**penalty_filter).aggregate( + agg = filter_penalty_qs( + Penalty.query.filter(**penalty_filter), request, + ).aggregate( count=Count('id'), amount=Sum('FineAmount'), fenhong_amount=Sum('ApplicantBonusAmount'), @@ -8073,7 +8083,9 @@ class CwqtczhqView(APIView): filter_kwargs['CreateTime__month'] = summary_q.month else: filter_kwargs['CreateTime__year'] = summary_q.year - agg = Czjilu.query.filter(**filter_kwargs).aggregate(count=Count('id'), amount=Sum('jine')) + agg = filter_queryset_by_club( + Czjilu.query.filter(**filter_kwargs), request, + ).aggregate(count=Count('id'), amount=Sum('jine')) count = agg['count'] or 0 amount = float(agg['amount'] or 0) profit = amount if lx == 3 else 0.0 @@ -8422,7 +8434,10 @@ class KhgglView(APIView): filter_bankuai_name = request.data.get('bankuai_name', '').strip() # 基础查询:所有审核官,预加载用户及打手扩展表 - queryset = UserShenheguan.query.select_related('user__DashouProfile') + queryset = filter_user_related_by_club( + UserShenheguan.query.select_related('user__DashouProfile'), + request, + ) if filter_yonghuid: queryset = queryset.filter(user__UserUID=filter_yonghuid) @@ -8446,14 +8461,19 @@ class KhgglView(APIView): end = start + page_size shenheguan_list = queryset.order_by('-CreateTime')[start:end] + from jituan.services.shenhe_club import yonghu_chenghao_qs_for_request + # ---------- 预计算每个板块的打手数量 ---------- bankuai_dashou_count_map = {} all_banquai = Bankuai.query.all() for bk in all_banquai: tag_ids = Chenghao.query.filter(bankuai=bk, leixing='dashou').values_list('id', flat=True) - count = YonghuChenghao.query.filter( - chenghao_id__in=tag_ids, - yonghu__DashouProfile__isnull=False + count = yonghu_chenghao_qs_for_request( + YonghuChenghao.query.filter( + chenghao_id__in=tag_ids, + yonghu__DashouProfile__isnull=False, + ), + request, ).values('yonghu').distinct().count() bankuai_dashou_count_map[bk.bankuai_id] = count @@ -8463,7 +8483,10 @@ class KhgglView(APIView): tags = Chenghao.query.filter(bankuai=bk, leixing='dashou') tag_list = [] for tag in tags: - user_count = YonghuChenghao.query.filter(chenghao=tag).values('yonghu').distinct().count() + user_count = yonghu_chenghao_qs_for_request( + YonghuChenghao.query.filter(chenghao=tag), + request, + ).values('yonghu').distinct().count() tag_list.append({ 'id': tag.id, 'name': tag.mingcheng, @@ -8772,6 +8795,7 @@ class ZxkfghdsView(APIView): Remark=old_order.Remark, User1ID=old_order.User1ID, # 商家标识 ImageURL=old_order.ImageURL, + ClubID=getattr(old_order, 'ClubID', None) or 'xq', # 注意:不继承 AssignedID、PlayerID ) @@ -9022,13 +9046,24 @@ class KhjlglView(APIView): if page_size > 100: page_size = 100 + from jituan.services.shenhe_club import filter_shenhe_jilu_by_request + filter_data = request.data - qs = _build_shenhe_jilu_filters(filter_data) + qs = filter_shenhe_jilu_by_request( + _build_shenhe_jilu_filters(filter_data), + request, + ) # FluentQuery.filter() 会原地修改同一对象;统计与列表必须各自独立查询 - stats_base = _build_shenhe_jilu_filters(filter_data) + stats_base = filter_shenhe_jilu_by_request( + _build_shenhe_jilu_filters(filter_data), + request, + ) agg = stats_base.aggregate(total_fee=Sum('jiaofei_jine')) - tag_rows = _build_shenhe_jilu_filters(filter_data).values( + tag_rows = filter_shenhe_jilu_by_request( + _build_shenhe_jilu_filters(filter_data), + request, + ).values( 'chenghao_id', 'chenghao__mingcheng' ).annotate( total=Count('jilu_id'), @@ -9040,10 +9075,10 @@ class KhjlglView(APIView): stats = { 'total': stats_base.count(), - 'passed': _build_shenhe_jilu_filters(filter_data).filter(zhuangtai=1).count(), - 'failed': _build_shenhe_jilu_filters(filter_data).filter(zhuangtai=2).count(), - 'in_progress': _build_shenhe_jilu_filters(filter_data).filter(zhuangtai=0).count(), - 'pending': _build_shenhe_jilu_filters(filter_data).filter(zhuangtai=3).count(), + 'passed': stats_base.filter(zhuangtai=1).count(), + 'failed': stats_base.filter(zhuangtai=2).count(), + 'in_progress': stats_base.filter(zhuangtai=0).count(), + 'pending': stats_base.filter(zhuangtai=3).count(), 'total_fee': float(agg['total_fee'] or 0), 'tag_stats': [ { diff --git a/config/migrations/0004_daily_stat_club_id.py b/config/migrations/0004_daily_stat_club_id.py new file mode 100644 index 0000000..0dbd64c --- /dev/null +++ b/config/migrations/0004_daily_stat_club_id.py @@ -0,0 +1,47 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('config', '0003_remove_shangjialianjie_shangjia_li_yonghu__9f2b25_idx_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='dailyincomestat', + name='club_id', + field=models.CharField(db_index=True, default='xq', max_length=16, verbose_name='所属俱乐部'), + ), + migrations.AddField( + model_name='dailypayoutstat', + name='club_id', + field=models.CharField(db_index=True, default='xq', max_length=16, verbose_name='所属俱乐部'), + ), + migrations.AlterField( + model_name='dailyincomestat', + name='date', + field=models.DateField(db_index=True, verbose_name='统计日期'), + ), + migrations.AlterField( + model_name='dailypayoutstat', + name='date', + field=models.DateField(db_index=True, verbose_name='统计日期'), + ), + migrations.AddIndex( + model_name='dailyincomestat', + index=models.Index(fields=['club_id', 'date'], name='idx_income_club_date'), + ), + migrations.AddIndex( + model_name='dailypayoutstat', + index=models.Index(fields=['club_id', 'date'], name='idx_payout_club_date'), + ), + migrations.AddConstraint( + model_name='dailyincomestat', + constraint=models.UniqueConstraint(fields=('date', 'club_id'), name='uniq_daily_income_club_date'), + ), + migrations.AddConstraint( + model_name='dailypayoutstat', + constraint=models.UniqueConstraint(fields=('date', 'club_id'), name='uniq_daily_payout_club_date'), + ), + ] diff --git a/config/migrations/0005_szjilu_club_id.py b/config/migrations/0005_szjilu_club_id.py new file mode 100644 index 0000000..0f9dc89 --- /dev/null +++ b/config/migrations/0005_szjilu_club_id.py @@ -0,0 +1,37 @@ +from django.db import migrations, models + + +def backfill_szjilu_club_id(apps, schema_editor): + Szjilu = apps.get_model('config', 'Szjilu') + for row in Szjilu.objects.all(): + if not getattr(row, 'club_id', None): + row.club_id = 'xq' + row.save(update_fields=['club_id']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('config', '0004_daily_stat_club_id'), + ] + + operations = [ + migrations.AddField( + model_name='szjilu', + name='club_id', + field=models.CharField( + db_index=True, + default='xq', + max_length=16, + verbose_name='俱乐部ID', + ), + ), + migrations.RunPython(backfill_szjilu_club_id, migrations.RunPython.noop), + migrations.AddConstraint( + model_name='szjilu', + constraint=models.UniqueConstraint( + fields=('club_id',), + name='uniq_szjilu_club_id', + ), + ), + ] diff --git a/config/migrations/0006_display_club_id.py b/config/migrations/0006_display_club_id.py new file mode 100644 index 0000000..28c80f1 --- /dev/null +++ b/config/migrations/0006_display_club_id.py @@ -0,0 +1,60 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('config', '0005_szjilu_club_id'), + ] + + operations = [ + migrations.AddField( + model_name='gonggao', + name='club_id', + field=models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID'), + ), + migrations.AddField( + model_name='lunbo', + name='club_id', + field=models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID'), + ), + migrations.AddField( + model_name='lunbo', + name='page_key', + field=models.CharField( + db_index=True, default='order_pool', max_length=32, + verbose_name='页面标识(order_pool/accept_order/merchant_home/dashou_center)', + ), + ), + migrations.AddField( + model_name='popuppage', + name='club_id', + field=models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID'), + ), + migrations.AddField( + model_name='tupianpeizhi', + name='club_id', + field=models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID'), + ), + migrations.AlterUniqueTogether( + name='popuppage', + unique_together=set(), + ), + migrations.AlterField( + model_name='popuppage', + name='page_key', + field=models.CharField(db_index=True, max_length=50, verbose_name='页面标识'), + ), + migrations.AlterUniqueTogether( + name='popuppage', + unique_together={('club_id', 'page_key')}, + ), + migrations.AlterUniqueTogether( + name='tupianpeizhi', + unique_together={('club_id', 'ImageType')}, + ), + migrations.AddIndex( + model_name='lunbo', + index=models.Index(fields=['club_id', 'page_key', 'ImageType'], name='lunbo_club_page_type_idx'), + ), + ] diff --git a/config/models.py b/config/models.py index b818654..40244ca 100644 --- a/config/models.py +++ b/config/models.py @@ -3,6 +3,11 @@ from gvsdsdk.model_base import QModel class Lunbo(QModel): + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID') + page_key = models.CharField( + max_length=32, default='order_pool', db_index=True, + verbose_name='页面标识(order_pool/accept_order/merchant_home/dashou_center)', + ) ImageURL = models.CharField(max_length=500, null=True, blank=True, verbose_name='轮播图片URL') ImageType = models.IntegerField(null=True, blank=True, verbose_name='图片类型:1:轮播2:个人中心背景') @@ -12,10 +17,13 @@ class Lunbo(QModel): db_table = 'lunbo' verbose_name = '轮播' verbose_name_plural = '轮播' - # 不要索引!数据量太小 + indexes = [ + models.Index(fields=['club_id', 'page_key', 'ImageType']), + ] class Gonggao(QModel): + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID') NoticeType = models.IntegerField(null=True, blank=True, verbose_name='公告类型') CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更改时间') @@ -37,6 +45,7 @@ class Qunpeizhi(QModel): db_table = 'qunpeizhi' class Tupianpeizhi(QModel): + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID') ImageURL = models.CharField(max_length=500, null=True, blank=True, verbose_name='配置图片URL') ImageType = models.PositiveIntegerField(null=True, blank=True, verbose_name='提现类型1:打手规则图片2:默认头像图片') CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') @@ -46,10 +55,12 @@ class Tupianpeizhi(QModel): db_table = 'tupianpeizhi' verbose_name = '配置图片' verbose_name_plural = '配置图片' + unique_together = [['club_id', 'ImageType']] class Szjilu(QModel): id = models.AutoField(primary_key=True, verbose_name='记录ID') + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID') TotalIncome = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name='总收益') TotalFlow = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name='总流水') TotalExpense = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name='总支出') @@ -61,6 +72,9 @@ class Szjilu(QModel): db_table = 'szjilu' verbose_name = '收支记录' verbose_name_plural = '收支记录' + constraints = [ + models.UniqueConstraint(fields=['club_id'], name='uniq_szjilu_club_id'), + ] @@ -277,7 +291,10 @@ class DailyIncomeStat(QModel): 记录平台每天通过微信支付获得的总收入和总笔数 """ # 日期相关字段(便于快速筛选和分组) - date = models.DateField(verbose_name='统计日期', db_index=True, unique=True) + date = models.DateField(verbose_name='统计日期', db_index=True) + club_id = models.CharField( + max_length=16, default='xq', db_index=True, verbose_name='所属俱乐部', + ) year = models.PositiveSmallIntegerField(verbose_name='年份') month = models.PositiveSmallIntegerField(verbose_name='月份') day = models.PositiveSmallIntegerField(verbose_name='日期') @@ -297,6 +314,10 @@ class DailyIncomeStat(QModel): indexes = [ models.Index(fields=['date']), models.Index(fields=['year', 'month', 'day']), + models.Index(fields=['club_id', 'date']), + ] + constraints = [ + models.UniqueConstraint(fields=['date', 'club_id'], name='uniq_daily_income_club_date'), ] def __str__(self): @@ -310,7 +331,10 @@ class DailyPayoutStat(QModel): 每日出款统计表 记录平台每天通过提现/结算等方式的出款总额和总笔数 """ - date = models.DateField(verbose_name='统计日期', db_index=True, unique=True) + date = models.DateField(verbose_name='统计日期', db_index=True) + club_id = models.CharField( + max_length=16, default='xq', db_index=True, verbose_name='所属俱乐部', + ) year = models.PositiveSmallIntegerField(verbose_name='年份') month = models.PositiveSmallIntegerField(verbose_name='月份') day = models.PositiveSmallIntegerField(verbose_name='日期') @@ -328,6 +352,10 @@ class DailyPayoutStat(QModel): indexes = [ models.Index(fields=['date']), models.Index(fields=['year', 'month', 'day']), + models.Index(fields=['club_id', 'date']), + ] + constraints = [ + models.UniqueConstraint(fields=['date', 'club_id'], name='uniq_daily_payout_club_date'), ] def __str__(self): @@ -337,7 +365,8 @@ class DailyPayoutStat(QModel): class PopupPage(QModel): - page_key = models.CharField(max_length=50, unique=True, db_index=True, verbose_name='页面标识') + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID') + page_key = models.CharField(max_length=50, db_index=True, verbose_name='页面标识') name = models.CharField(max_length=100, verbose_name='页面名称') description = models.TextField(blank=True, verbose_name='描述') is_active = models.BooleanField(default=True, verbose_name='是否启用') @@ -349,6 +378,7 @@ class PopupPage(QModel): db_table = 'popup_page' verbose_name = '弹窗页面' verbose_name_plural = '弹窗页面' + unique_together = [['club_id', 'page_key']] def __str__(self): return f'{self.name} ({self.page_key})' diff --git a/config/tasks.py b/config/tasks.py index f3faf65..d74b772 100644 --- a/config/tasks.py +++ b/config/tasks.py @@ -1,76 +1,34 @@ """ -阿龙电竞 - 收支记录定时任务 -处理收支记录表的每日清零 -固定使用ID=1的记录 +收支记录定时任务:每日清零各俱乐部今日流水/今日支出。 """ from celery import shared_task -from django.db import transaction -from django.utils import timezone from datetime import datetime -from decimal import Decimal import logging -# 导入模型 -from config.models import Szjilu +from jituan.services.szjilu_accounting import reset_all_daily_szjilu from utils.celery_utils import log_task_execution, rollback_on_failure logger = logging.getLogger(__name__) + @shared_task @rollback_on_failure("收支记录每日清零") def daily_sz_reset_task(): - """ - 收支记录每日清零 - 每天凌晨0点5分执行 - 清零收支记录表的今日相关字段,固定使用ID=1的记录 - """ + """收支记录每日清零 - 每天凌晨0点5分执行。""" try: - with transaction.atomic(): - # 获取今天的日期 - today = datetime.now().strftime('%Y-%m-%d') + today = datetime.now().strftime('%Y-%m-%d') + logger.info(f"开始执行收支记录清零任务,日期: {today}") - logger.info(f"开始执行收支记录清零任务,日期: {today}") + updated = reset_all_daily_szjilu() + result_msg = f"收支记录清零完成 - 日期: {today}, 俱乐部记录数: {updated}" + log_task_execution("收支记录每日清零", True, result_msg) - # 查询ID=1的收支记录,如果不存在则创建 - try: - szjilu = Szjilu.objects.get(id=1) - logger.info(f"找到ID=1的收支记录") - except Szjilu.DoesNotExist: - # 创建ID=1的初始记录 - szjilu = Szjilu.objects.create( - id=1, - TotalIncome=Decimal('0.00'), - TotalFlow=Decimal('0.00'), - TotalExpense=Decimal('0.00'), - DailyExpense=Decimal('0.00'), - DailyFlow=Decimal('0.00') - ) - logger.info(f"创建ID=1的收支记录") - - # 记录清零前的值(用于日志) - old_jrzc = szjilu.DailyExpense - old_jrls = szjilu.DailyFlow - - # 清零今日字段 - szjilu.DailyExpense = Decimal('0.00') - szjilu.DailyFlow = Decimal('0.00') - szjilu.save(update_fields=['DailyExpense', 'DailyFlow', 'UpdateTime']) - - logger.info(f"收支记录清零完成,今日支出原值: {old_jrzc},今日流水原值: {old_jrls}") - - result_msg = f"收支记录清零完成 - 日期: {today}, 记录ID: {szjilu.id}" - - log_task_execution("收支记录每日清零", True, result_msg) - - return { - "success": True, - "date": today, - "record_id": szjilu.id, - "jrzc_cleared": True, - "jrls_cleared": True, - "old_jrzc": str(old_jrzc), - "old_jrls": str(old_jrls), - "message": result_msg - } + return { + "success": True, + "date": today, + "clubs_updated": updated, + "message": result_msg, + } except Exception as e: error_msg = f"收支记录每日清零失败: {str(e)}" @@ -81,5 +39,5 @@ def daily_sz_reset_task(): "success": False, "error": str(e), "date": datetime.now().strftime('%Y-%m-%d'), - "message": error_msg + "message": error_msg, } \ No newline at end of file diff --git a/config/views.py b/config/views.py index 7acb9ce..c3557ad 100644 --- a/config/views.py +++ b/config/views.py @@ -132,20 +132,17 @@ class ShangpinGonggaoView(APIView): """ try: logger.info("收到商品公告和轮播图请求") - # 1. 查询公告(类型为1) - gonggao_obj = Gonggao.query.filter(NoticeType=1).first() - # 获取公告内容,如果没有则返回空字符串 - gonggao_content = gonggao_obj.Content if gonggao_obj else "" - # 2. 查询轮播图(类型为1,按显示顺序排序) - lunbo_queryset = Lunbo.query.filter(ImageType=1) - # 提取轮播图URL列表 - lunbo_urls = [lunbo.ImageURL for lunbo in lunbo_queryset] - # 3. 构建返回数据 + from jituan.services.display_config import get_gonggao_content, get_lunbo_urls, normalize_page_key + + page_key = normalize_page_key(request.data.get('page_key'), image_type=1) + gonggao_content = get_gonggao_content(request, notice_type=1) + lunbo_urls = get_lunbo_urls(request, page_key=page_key, image_type=1) response_data = { "shangpingonggao": gonggao_content, - "shangpinlunbo": lunbo_urls + "shangpinlunbo": lunbo_urls, + "page_key": page_key, } - logger.info(f"返回数据:公告长度{len(gonggao_content)},轮播图数量{len(lunbo_urls)}") + logger.info(f"返回数据:公告长度{len(gonggao_content)},轮播图数量{len(lunbo_urls)} page_key={page_key}") # 4. 返回响应 return Response(response_data, status=status.HTTP_200_OK) except Exception as e: @@ -3158,9 +3155,13 @@ class PopupConfigView(APIView): 'msg': '缺少参数 pageKey' }, status=status.HTTP_400_BAD_REQUEST) - # 3. 查询页面配置 + # 3. 查询页面配置(按俱乐部) + from jituan.services.display_config import get_gonggao_content + from jituan.services.club_context import resolve_club_id_from_request + + club_id = resolve_club_id_from_request(request) try: - popup_page = PopupPage.query.get(page_key=page_key, is_active=True) + popup_page = PopupPage.query.get(club_id=club_id, page_key=page_key, is_active=True) except PopupPage.DoesNotExist: # 该页面没有配置弹窗,返回空 return Response({ @@ -3206,15 +3207,21 @@ class GetWithdrawModeView(APIView): permission_classes = [IsAuthenticated] # JWT 认证 def post(self, request, *args, **kwargs): - # 尝试获取 ID=1 的记录 - config = WithdrawConfig.query.filter(id=1).first() - # 如果存在则取实际值,否则默认 2(手动提现) - mode = config.mode if config else 2 + from jituan.services.club_context import resolve_club_id_from_request + from jituan.services.club_user import get_user_club_id + from jituan.services.withdraw_config import get_withdraw_mode + + user = getattr(request, 'user', None) + club_id = get_user_club_id(user) if user and getattr(user, 'is_authenticated', False) else None + if not club_id: + club_id = resolve_club_id_from_request(request) + mode = get_withdraw_mode(club_id) return Response({ 'code': 0, 'data': { - 'mode': mode + 'mode': mode, + 'club_id': club_id, } }) diff --git a/jituan/__init__.py b/jituan/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jituan/apps.py b/jituan/apps.py new file mode 100644 index 0000000..633888f --- /dev/null +++ b/jituan/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class JituanConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'jituan' + verbose_name = '集团多俱乐部' diff --git a/jituan/constants.py b/jituan/constants.py new file mode 100644 index 0000000..1215f25 --- /dev/null +++ b/jituan/constants.py @@ -0,0 +1,27 @@ +"""集团多俱乐部常量。""" + +CLUB_ID_DEFAULT = 'xq' +CLUB_HEADER = 'X-Club-Id' +CLUB_SCOPE_HEADER = 'X-Club-Scope' + +DATA_SCOPE_ALL = 'ALL_CLUBS' +DATA_SCOPE_SINGLE = 'SINGLE_CLUB' + +# 管理任职角色码(数据范围 / 集团 vs 子公司) +ADMIN_ROLE_LABELS = { + 'GROUP_OWNER': '集团总负责人', + 'GROUP_SUPER_ADMIN': '集团超管', + 'GROUP_AFTER_SALES': '集团售后', + 'GROUP_FINANCE': '集团财务', + 'GROUP_CONFIG': '集团配置', + 'CLUB_OWNER': '俱乐部总负责人', + 'CLUB_ADMIN': '俱乐部管理员', + 'CLUB_AFTER_SALES': '俱乐部售后', + 'CLUB_FINANCE': '俱乐部财务', + 'CLUB_OPERATOR': '俱乐部运营', +} + +# 功能权限仍走 gvsdsdk Permission.perm_code(如 caiwu、dingdan、000001) + +ADMIN_SCOPE_GROUP = 'GROUP' +ADMIN_SCOPE_CLUB = 'CLUB' diff --git a/jituan/management/__init__.py b/jituan/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jituan/management/commands/__init__.py b/jituan/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jituan/management/commands/backfill_display_club_id.py b/jituan/management/commands/backfill_display_club_id.py new file mode 100644 index 0000000..14adc32 --- /dev/null +++ b/jituan/management/commands/backfill_display_club_id.py @@ -0,0 +1,47 @@ +"""回填 lunbo / gonggao / popup_page / tupianpeizhi 的 club_id 与 lunbo.page_key。""" +from django.core.management.base import BaseCommand +from django.db import transaction + +from jituan.constants import CLUB_ID_DEFAULT +from config.models import Gonggao, Lunbo, PopupPage, Tupianpeizhi + + +class Command(BaseCommand): + help = '回填展示配置表的 club_id,并为 lunbo 补 page_key' + + def handle(self, *args, **options): + updated = {'lunbo_club': 0, 'lunbo_page': 0, 'gonggao': 0, 'popup': 0, 'tupian': 0} + + with transaction.atomic(): + for row in Lunbo.query.all().only('id', 'club_id', 'page_key', 'ImageType'): + fields = [] + if not row.club_id: + row.club_id = CLUB_ID_DEFAULT + fields.append('club_id') + updated['lunbo_club'] += 1 + if not row.page_key: + if row.ImageType == 2: + row.page_key = 'dashou_center' + else: + row.page_key = 'order_pool' + fields.append('page_key') + updated['lunbo_page'] += 1 + if fields: + row.save(update_fields=fields) + + for row in Gonggao.query.filter(club_id='').only('id', 'club_id'): + Gonggao.query.filter(id=row.id).update(club_id=CLUB_ID_DEFAULT) + updated['gonggao'] += 1 + + for row in PopupPage.query.filter(club_id='').only('id', 'club_id'): + PopupPage.query.filter(id=row.id).update(club_id=CLUB_ID_DEFAULT) + updated['popup'] += 1 + + for row in Tupianpeizhi.query.filter(club_id='').only('id', 'club_id'): + Tupianpeizhi.query.filter(id=row.id).update(club_id=CLUB_ID_DEFAULT) + updated['tupian'] += 1 + + self.stdout.write(self.style.SUCCESS( + f'回填完成: lunbo_club={updated["lunbo_club"]}, lunbo_page={updated["lunbo_page"]}, ' + f'gonggao={updated["gonggao"]}, popup={updated["popup"]}, tupian={updated["tupian"]}' + )) diff --git a/jituan/management/commands/backfill_gsfenhong_club_id.py b/jituan/management/commands/backfill_gsfenhong_club_id.py new file mode 100644 index 0000000..f697f96 --- /dev/null +++ b/jituan/management/commands/backfill_gsfenhong_club_id.py @@ -0,0 +1,23 @@ +"""按充值订单 / 订单 / 用户回填 gsfenhong.club_id。""" +from django.core.management.base import BaseCommand +from django.db import transaction + +from jituan.constants import CLUB_ID_DEFAULT +from jituan.services.club_penalty import resolve_gsfenhong_club_id +from products.models import Gsfenhong + + +class Command(BaseCommand): + help = '回填 gsfenhong.club_id' + + def handle(self, *args, **options): + updated = 0 + with transaction.atomic(): + for row in Gsfenhong.query.all().only('dingdan_id', 'dashouid', 'club_id'): + cid = resolve_gsfenhong_club_id(dingdan_id=row.dingdan_id, dashouid=row.dashouid) + if not cid: + cid = CLUB_ID_DEFAULT + if row.club_id != cid: + Gsfenhong.query.filter(dingdan_id=row.dingdan_id).update(club_id=cid) + updated += 1 + self.stdout.write(self.style.SUCCESS(f'gsfenhong club_id 更新 {updated} 条')) diff --git a/jituan/management/commands/backfill_penalty_club_id.py b/jituan/management/commands/backfill_penalty_club_id.py new file mode 100644 index 0000000..6c00ec1 --- /dev/null +++ b/jituan/management/commands/backfill_penalty_club_id.py @@ -0,0 +1,42 @@ +"""回填罚单 club_id(从关联订单或被罚用户)。""" +from django.core.management.base import BaseCommand +from django.db import transaction + +from jituan.constants import CLUB_ID_DEFAULT +from jituan.services.club_penalty import resolve_penalty_club_id +from orders.models import Order, Penalty +from users.business_models import User + + +class Command(BaseCommand): + help = '按关联订单/被罚用户回填 fadan.club_id(默认仅处理仍为 xq 的记录)' + + def add_arguments(self, parser): + parser.add_argument('--all', action='store_true', help='重算全部罚单(慎用)') + parser.add_argument('--dry-run', action='store_true') + + def handle(self, *args, **options): + dry = options['dry_run'] + qs = Penalty.query.all() + if not options['all']: + qs = qs.filter(ClubID=CLUB_ID_DEFAULT) + + updated = 0 + with transaction.atomic(): + for p in qs.iterator(): + order = None + if p.RelatedOrderID: + order = Order.query.filter(OrderID=p.RelatedOrderID).first() + cid = resolve_penalty_club_id( + order=order, + penalized_user_id=p.PenalizedUserID, + ) + if cid and cid != p.ClubID: + if dry: + self.stdout.write(f'would update penalty {p.id}: {p.ClubID} -> {cid}') + else: + p.ClubID = cid + p.save(update_fields=['ClubID']) + updated += 1 + + self.stdout.write(self.style.SUCCESS(f'罚单 club_id 更新 {updated} 条')) diff --git a/jituan/management/commands/backfill_penalty_record_club_id.py b/jituan/management/commands/backfill_penalty_record_club_id.py new file mode 100644 index 0000000..6688431 --- /dev/null +++ b/jituan/management/commands/backfill_penalty_record_club_id.py @@ -0,0 +1,35 @@ +"""回填 chufajilu / duoci_fenhong club_id。""" +from django.core.management.base import BaseCommand +from django.db import transaction + +from jituan.constants import CLUB_ID_DEFAULT +from jituan.services.club_penalty import resolve_gsfenhong_club_id, resolve_penalty_record_club_id +from orders.models import PenaltyRecord +from products.models import DuociFenhong + + +class Command(BaseCommand): + help = '回填 PenaltyRecord / DuociFenhong 的 club_id' + + def handle(self, *args, **options): + pr_updated = 0 + df_updated = 0 + with transaction.atomic(): + for row in PenaltyRecord.query.all().only('id', 'OrderID', 'PlayerID', 'ClubID'): + cid = resolve_penalty_record_club_id(order_id=row.OrderID, player_id=row.PlayerID) + if row.ClubID != cid: + PenaltyRecord.query.filter(id=row.id).update(ClubID=cid) + pr_updated += 1 + for row in DuociFenhong.query.all().only('huiyuan', 'yonghuid', 'cishu', 'club_id'): + cid = resolve_gsfenhong_club_id(dashouid=row.yonghuid) or CLUB_ID_DEFAULT + if row.club_id != cid: + DuociFenhong.query.filter( + club_id=row.club_id, + huiyuan=row.huiyuan, + yonghuid=row.yonghuid, + cishu=row.cishu, + ).update(club_id=cid) + df_updated += 1 + self.stdout.write(self.style.SUCCESS( + f'PenaltyRecord={pr_updated}, DuociFenhong={df_updated}' + )) diff --git a/jituan/management/commands/backfill_role_daily_club_id.py b/jituan/management/commands/backfill_role_daily_club_id.py new file mode 100644 index 0000000..ca8c9f4 --- /dev/null +++ b/jituan/management/commands/backfill_role_daily_club_id.py @@ -0,0 +1,36 @@ +"""回填角色日统计 club_id(按用户归属俱乐部)。""" +from django.core.management.base import BaseCommand +from django.db import transaction + +from backend.models import ( + LeaderDailyStats, + ManagerDailyStats, + ManagerRenewalDailyStats, + MerchantDailyStats, + PlayerDailyStats, +) +from backend.utils import _club_id_for_yonghuid + + +MODELS = [ + (MerchantDailyStats, 'MerchantID'), + (PlayerDailyStats, 'PlayerID'), + (LeaderDailyStats, 'LeaderID'), + (ManagerDailyStats, 'ManagerID'), + (ManagerRenewalDailyStats, 'ManagerID'), +] + + +class Command(BaseCommand): + help = '回填 5 张角色日统计表的 club_id' + + def handle(self, *args, **options): + updated = 0 + with transaction.atomic(): + for model, uid_field in MODELS: + for row in model.query.all().only('id', uid_field, 'club_id'): + cid = _club_id_for_yonghuid(getattr(row, uid_field)) + if row.club_id != cid: + model.query.filter(id=row.id).update(club_id=cid) + updated += 1 + self.stdout.write(self.style.SUCCESS(f'角色日统计 club_id 更新 {updated} 条')) diff --git a/jituan/management/commands/backfill_tixian_club_id.py b/jituan/management/commands/backfill_tixian_club_id.py new file mode 100644 index 0000000..e6d960e --- /dev/null +++ b/jituan/management/commands/backfill_tixian_club_id.py @@ -0,0 +1,42 @@ +"""按用户 ClubID 回填提现流水 club_id。""" +from django.core.management.base import BaseCommand +from django.db import transaction + +from jituan.constants import CLUB_ID_DEFAULT +from jituan.services.club_user import get_user_club_id +from users.business_models import User +from users.models import TixianAutoRecord, Tixianjilu, TixianShenheJilu + + +class Command(BaseCommand): + help = '回填 tixianjilu / tixian_shenhe_jilu / tixian_auto_record 的 club_id' + + def handle(self, *args, **options): + uid_club = {} + for u in User.query.all().only('UserUID', 'ClubID'): + uid_club[u.UserUID] = get_user_club_id(u) + + updated = {'jilu': 0, 'shenhe': 0, 'auto': 0} + + with transaction.atomic(): + for row in Tixianjilu.query.all().only('id', 'yonghuid', 'club_id'): + cid = uid_club.get(row.yonghuid) or CLUB_ID_DEFAULT + if row.club_id != cid: + Tixianjilu.query.filter(id=row.id).update(club_id=cid) + updated['jilu'] += 1 + + for row in TixianShenheJilu.query.all().only('id', 'yonghuid', 'club_id'): + cid = uid_club.get(row.yonghuid) or CLUB_ID_DEFAULT + if row.club_id != cid: + TixianShenheJilu.query.filter(id=row.id).update(club_id=cid) + updated['shenhe'] += 1 + + for row in TixianAutoRecord.query.all().only('id', 'yonghuid', 'club_id'): + cid = uid_club.get(row.yonghuid) or CLUB_ID_DEFAULT + if row.club_id != cid: + TixianAutoRecord.query.filter(id=row.id).update(club_id=cid) + updated['auto'] += 1 + + self.stdout.write(self.style.SUCCESS( + f'回填完成: jilu={updated["jilu"]}, shenhe={updated["shenhe"]}, auto={updated["auto"]}' + )) diff --git a/jituan/management/commands/backfill_user_club_id.py b/jituan/management/commands/backfill_user_club_id.py new file mode 100644 index 0000000..e6abd96 --- /dev/null +++ b/jituan/management/commands/backfill_user_club_id.py @@ -0,0 +1,53 @@ +"""回填用户 ClubID(缺省或空时设为 xq 或 user_wx_openid 绑定)。""" +from django.core.management.base import BaseCommand +from django.db import transaction + +from jituan.constants import CLUB_ID_DEFAULT +from jituan.models import UserWxOpenid +from users.business_models import User + + +class Command(BaseCommand): + help = '回填 User.ClubID:OpenID 绑定 → user_wx_openid,否则默认 xq' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='仅统计不落库', + ) + + def handle(self, *args, **options): + dry_run = options.get('dry_run', False) + updated = 0 + skipped = 0 + + users = User.query.all() + for user in users: + current = getattr(user, 'ClubID', None) or '' + if current and current != CLUB_ID_DEFAULT: + skipped += 1 + continue + + new_club = CLUB_ID_DEFAULT + if user.OpenID: + binding = UserWxOpenid.query.filter(openid=user.OpenID).order_by('id').first() + if binding: + new_club = binding.club_id + + if current == new_club: + skipped += 1 + continue + + if dry_run: + self.stdout.write(f'[dry-run] {user.UserUID} -> ClubID={new_club}') + updated += 1 + continue + + with transaction.atomic(): + user.ClubID = new_club + user.save(update_fields=['ClubID']) + updated += 1 + + suffix = '(dry-run)' if dry_run else '' + self.stdout.write(self.style.SUCCESS(f'完成{suffix}:更新 {updated},跳过 {skipped}')) diff --git a/jituan/management/commands/create_club.py b/jituan/management/commands/create_club.py new file mode 100644 index 0000000..09df976 --- /dev/null +++ b/jituan/management/commands/create_club.py @@ -0,0 +1,104 @@ +"""从 xq 模板复制配置,创建新子公司俱乐部。""" +from decimal import Decimal + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from jituan.constants import CLUB_ID_DEFAULT +from jituan.models import Club, ClubHuiyuanPrice, ClubLilubiao, ClubTixianQuota, ClubWithdrawConfig +from jituan.services.szjilu_accounting import normalize_szjilu_club_id +from jituan.services.display_config import copy_display_config +from config.models import Szjilu + + +class Command(BaseCommand): + help = '从 xq 复制支付/会员价/利率配置,创建新俱乐部' + + def add_arguments(self, parser): + parser.add_argument('club_id', type=str, help='新俱乐部 ID,如 xy') + parser.add_argument('--name', type=str, required=True, help='展示名称') + parser.add_argument('--wx-appid', type=str, default='', help='小程序 appid') + parser.add_argument('--from-club', type=str, default=CLUB_ID_DEFAULT, help='模板俱乐部') + + def handle(self, *args, **options): + new_id = (options['club_id'] or '').strip() + name = (options['name'] or '').strip() + template_id = normalize_szjilu_club_id(options.get('from_club')) + + if not new_id or len(new_id) > 16: + raise CommandError('club_id 无效') + if new_id == template_id: + raise CommandError('新 club_id 不能与模板相同') + + try: + template = Club.query.get(club_id=template_id) + except Club.DoesNotExist: + raise CommandError(f'模板俱乐部 {template_id} 不存在,请先 seed_club_xq') + + with transaction.atomic(): + if Club.query.filter(club_id=new_id).exists(): + raise CommandError(f'俱乐部 {new_id} 已存在') + + Club.query.create( + club_id=new_id, + name=name, + status=1, + wx_appid=options.get('wx_appid') or template.wx_appid, + wx_secret=template.wx_secret, + mch_id=template.mch_id, + pay_app_id=template.pay_app_id or options.get('wx_appid') or template.wx_appid, + api_v3_key=template.api_v3_key, + cert_serial_no=template.cert_serial_no, + private_key_path=template.private_key_path, + platform_cert_dir=template.platform_cert_dir, + official_appid=template.official_appid, + official_secret=template.official_secret, + official_token=template.official_token, + encoding_aes_key=template.encoding_aes_key, + template_id=template.template_id, + template_max_per_minute=template.template_max_per_minute, + h5_domain=template.h5_domain, + oss_prefix=template.oss_prefix or new_id, + goeasy_appkey=template.goeasy_appkey, + goeasy_secret=template.goeasy_secret, + config_json=dict(template.config_json or {}), + sort_order=template.sort_order + 1, + ) + + for row in ClubHuiyuanPrice.query.filter(club_id=template_id): + ClubHuiyuanPrice.query.create( + club_id=new_id, + huiyuan_id=row.huiyuan_id, + jiage=row.jiage or Decimal('0'), + guanshifc=row.guanshifc or Decimal('0'), + zuzhangfc=row.zuzhangfc or Decimal('0'), + is_enabled=row.is_enabled, + bankuai_id=row.bankuai_id, + ) + + for row in ClubLilubiao.query.filter(club_id=template_id): + ClubLilubiao.query.create( + club_id=new_id, + config_type=row.config_type, + lilv=row.lilv or Decimal('0'), + remark=row.remark or '', + ) + + try: + tpl_wd = ClubWithdrawConfig.query.get(club_id=template_id) + ClubWithdrawConfig.query.create(club_id=new_id, mode=tpl_wd.mode) + except ClubWithdrawConfig.DoesNotExist: + ClubWithdrawConfig.query.create(club_id=new_id, mode=1) + + for row in ClubTixianQuota.query.filter(club_id=template_id): + ClubTixianQuota.query.create( + club_id=new_id, + leixing=row.leixing, + daily_limit=row.daily_limit or Decimal('0'), + ) + + Szjilu.objects.get_or_create(club_id=new_id) + + copy_display_config(template_id, new_id) + + self.stdout.write(self.style.SUCCESS(f'俱乐部 {new_id} ({name}) 已创建,配置自 {template_id} 复制')) diff --git a/jituan/management/commands/seed_admin_assignments.py b/jituan/management/commands/seed_admin_assignments.py new file mode 100644 index 0000000..c3cf88f --- /dev/null +++ b/jituan/management/commands/seed_admin_assignments.py @@ -0,0 +1,65 @@ +"""为后台客服账号初始化 admin_assignment(数据范围,非功能权限)。""" +import logging + +from django.core.management.base import BaseCommand +from django.db import transaction + +from jituan.constants import CLUB_ID_DEFAULT, DATA_SCOPE_SINGLE, ADMIN_ROLE_LABELS +from jituan.models import AdminAssignment +from users.business_models import User + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = '为 kefu 账号创建默认子公司任职(club=xq, CLUB_ADMIN);超管可不建 assignment' + + def add_arguments(self, parser): + parser.add_argument( + '--club-id', default=CLUB_ID_DEFAULT, help='目标俱乐部 ID,默认 xq', + ) + parser.add_argument( + '--role-code', default='CLUB_ADMIN', help='任职角色码', + ) + parser.add_argument( + '--dry-run', action='store_true', help='只打印不写入', + ) + + def handle(self, *args, **options): + club_id = options['club_id'] + role_code = options['role_code'] + dry_run = options['dry_run'] + + kefu_users = User.query.filter(UserType='kefu') + created = 0 + skipped = 0 + + with transaction.atomic(): + for u in kefu_users: + if u.IsSuperuser: + skipped += 1 + continue + exists = AdminAssignment.query.filter( + yonghuid=u.UserUID, club_id=club_id, role_code=role_code, + ).exists() + if exists: + skipped += 1 + continue + if dry_run: + self.stdout.write(f'would assign {u.UserUID} -> {club_id} {role_code}') + created += 1 + continue + AdminAssignment.query.create( + yonghuid=u.UserUID, + club_id=club_id, + role_code=role_code, + data_scope=DATA_SCOPE_SINGLE, + is_primary=True, + status=1, + ) + created += 1 + + label = ADMIN_ROLE_LABELS.get(role_code, role_code) + self.stdout.write(self.style.SUCCESS( + f'完成:新建 {created} 条,跳过 {skipped} 条(club={club_id}, role={label})' + )) diff --git a/jituan/management/commands/seed_club_xq.py b/jituan/management/commands/seed_club_xq.py new file mode 100644 index 0000000..adf2a3b --- /dev/null +++ b/jituan/management/commands/seed_club_xq.py @@ -0,0 +1,183 @@ +"""从现网配置初始化 xq 俱乐部及覆盖表。""" +import logging +from decimal import Decimal + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.db import transaction + +from jituan.constants import CLUB_ID_DEFAULT +from jituan.models import ( + Club, + ClubHuiyuanPrice, + ClubLilubiao, + ClubWithdrawConfig, + UserWxOpenid, +) +from users.business_models import User + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = '初始化星阙(xq)俱乐部主数据,并从 huiyuan/lilubiao 复制覆盖配置' + + def handle(self, *args, **options): + with transaction.atomic(): + club, created = self._ensure_club_xq() + self.stdout.write(self.style.SUCCESS( + f'club xq: {"新建" if created else "已存在"}' + )) + self._copy_huiyuan_prices() + self._copy_lilubiao() + self._ensure_withdraw_config() + migrated = self._migrate_openid_bindings() + self.stdout.write(self.style.SUCCESS(f'user_wx_openid 迁移绑定: {migrated} 条')) + + def _ensure_club_xq(self): + wx_v3 = dict(getattr(settings, 'WECHAT_PAY_V3_CONFIG', {}) or {}) + defaults = { + 'name': '星阙电竞', + 'status': 1, + 'wx_appid': getattr(settings, 'WEIXIN_APPID', ''), + 'wx_secret': getattr(settings, 'WEIXIN_SECRET', ''), + 'mch_id': getattr(settings, 'WEIXIN_MCHID', '') or wx_v3.get('MCHID', ''), + 'pay_app_id': wx_v3.get('APPID', '') or getattr(settings, 'WEIXIN_APPID', ''), + 'api_v3_key': wx_v3.get('API_V3_KEY', ''), + 'cert_serial_no': wx_v3.get('CERT_SERIAL_NO', ''), + 'private_key_path': wx_v3.get('PRIVATE_KEY_PATH', ''), + 'platform_cert_dir': wx_v3.get('PLATFORM_CERT_DIR', ''), + 'config_json': { + 'mch_key': getattr(settings, 'WEIXIN_SHANGHUMIYAO', ''), + 'transfer_scene_id': wx_v3.get('TRANSFER_SCENE_ID', '1005'), + }, + 'official_appid': getattr(settings, 'WEIXIN_OFFICIAL_APPID', ''), + 'official_secret': getattr(settings, 'WEIXIN_OFFICIAL_SECRET', ''), + 'official_token': getattr(settings, 'WEIXIN_OFFICIAL_TOKEN', ''), + 'encoding_aes_key': getattr(settings, 'WEIXIN_OFFICIAL_ENCODING_AES_KEY', ''), + 'template_id': getattr(settings, 'WEIXIN_TEMPLATE_ID', ''), + 'template_max_per_minute': getattr(settings, 'WEIXIN_TEMPLATE_MAX_PER_MINUTE', 950), + 'h5_domain': getattr(settings, 'H5_DOMAIN', ''), + 'goeasy_appkey': getattr(settings, 'GOEASY_APPKEY', ''), + 'goeasy_secret': getattr(settings, 'GOEASY_SECRET', ''), + 'sort_order': 0, + } + try: + club = Club.query.get(club_id=CLUB_ID_DEFAULT) + for k, v in defaults.items(): + if k == 'config_json': + merged = dict(club.config_json or {}) + for ck, cv in v.items(): + if cv and not merged.get(ck): + merged[ck] = cv + club.config_json = merged + continue + if v and not getattr(club, k, None): + setattr(club, k, v) + # V3 字段允许用 settings 补全(证书路径等) + for field in ('api_v3_key', 'cert_serial_no', 'private_key_path', 'platform_cert_dir', 'pay_app_id'): + val = defaults.get(field) + if val and not getattr(club, field, None): + setattr(club, field, val) + club.save() + return club, False + except Club.DoesNotExist: + club = Club.query.create(club_id=CLUB_ID_DEFAULT, **defaults) + return club, True + + def _copy_huiyuan_prices(self): + from products.models import Huiyuan + count = 0 + for h in Huiyuan.query.all(): + bankuai_id = getattr(h, 'bankuai_id', None) + existing = ClubHuiyuanPrice.query.filter( + club_id=CLUB_ID_DEFAULT, huiyuan_id=h.huiyuan_id + ).first() + if existing: + continue + ClubHuiyuanPrice.query.create( + club_id=CLUB_ID_DEFAULT, + huiyuan_id=h.huiyuan_id, + jiage=h.jiage or Decimal('0'), + guanshifc=h.guanshifc or Decimal('0'), + zuzhangfc=h.zuzhangfc or Decimal('0'), + is_enabled=True, + bankuai_id=bankuai_id, + ) + count += 1 + self.stdout.write(f'club_huiyuan_price 新增 {count} 条') + + def _copy_lilubiao(self): + from orders.models import CommissionRate + count = 0 + for row in CommissionRate.query.all(): + config_type = int(row.Platform or 0) + existing = ClubLilubiao.query.filter( + club_id=CLUB_ID_DEFAULT, config_type=config_type + ).first() + if existing: + continue + ClubLilubiao.query.create( + club_id=CLUB_ID_DEFAULT, + config_type=config_type, + lilv=row.Rate or Decimal('0'), + remark='', + ) + count += 1 + self.stdout.write(f'club_lilubiao 新增 {count} 条') + + def _ensure_withdraw_config(self): + from config.models import WithdrawConfig, TixianQuotaDefault + mode = 1 + try: + wc = WithdrawConfig.query.first() + if wc: + mode = wc.mode + except Exception: + pass + try: + cfg = ClubWithdrawConfig.query.get(club_id=CLUB_ID_DEFAULT) + cfg.mode = mode + cfg.save(update_fields=['mode']) + except ClubWithdrawConfig.DoesNotExist: + ClubWithdrawConfig.query.create(club_id=CLUB_ID_DEFAULT, mode=mode) + self._copy_tixian_quota() + + def _copy_tixian_quota(self): + from config.models import TixianQuotaDefault + from jituan.models import ClubTixianQuota + count = 0 + for row in TixianQuotaDefault.query.all(): + existing = ClubTixianQuota.query.filter( + club_id=CLUB_ID_DEFAULT, leixing=row.UserType, + ).first() + if existing: + continue + ClubTixianQuota.query.create( + club_id=CLUB_ID_DEFAULT, + leixing=row.UserType, + daily_limit=row.default_quota or Decimal('0'), + ) + count += 1 + self.stdout.write(f'club_tixian_quota 新增 {count} 条') + + def _migrate_openid_bindings(self): + migrated = 0 + users = User.query.filter(OpenID__isnull=False).exclude(OpenID='') + for u in users: + exists = UserWxOpenid.query.filter( + club_id=CLUB_ID_DEFAULT, openid=u.OpenID + ).exists() + if exists: + continue + UserWxOpenid.query.create( + club_id=CLUB_ID_DEFAULT, + openid=u.OpenID, + yonghuid=u.UserUID, + unionid=u.UnionID, + ) + if hasattr(u, 'ClubID') and (not u.ClubID or u.ClubID != CLUB_ID_DEFAULT): + u.ClubID = CLUB_ID_DEFAULT + u.save(update_fields=['ClubID']) + migrated += 1 + return migrated diff --git a/jituan/management/commands/seed_szjilu_clubs.py b/jituan/management/commands/seed_szjilu_clubs.py new file mode 100644 index 0000000..fcdeb86 --- /dev/null +++ b/jituan/management/commands/seed_szjilu_clubs.py @@ -0,0 +1,40 @@ +"""为每个启用俱乐部初始化 szjilu 收支流水行。""" +from django.core.management.base import BaseCommand +from django.db import transaction + +from config.models import Szjilu +from jituan.constants import CLUB_ID_DEFAULT +from jituan.models import Club +from jituan.services.szjilu_accounting import normalize_szjilu_club_id + + +class Command(BaseCommand): + help = '为每个俱乐部创建 szjilu 收支流水行(已有则跳过)' + + def add_arguments(self, parser): + parser.add_argument( + '--club-id', + type=str, + default='', + help='仅处理指定 club_id,默认处理全部启用俱乐部', + ) + + def handle(self, *args, **options): + club_id = (options.get('club_id') or '').strip() + if club_id: + club_ids = [normalize_szjilu_club_id(club_id)] + else: + club_ids = list( + Club.query.filter(status=1).values_list('club_id', flat=True) + ) + if CLUB_ID_DEFAULT not in club_ids: + club_ids.append(CLUB_ID_DEFAULT) + + created = 0 + with transaction.atomic(): + for cid in club_ids: + _, was_created = Szjilu.objects.get_or_create(club_id=cid) + if was_created: + created += 1 + self.stdout.write(f'创建 szjilu: club_id={cid}') + self.stdout.write(self.style.SUCCESS(f'完成,新建 {created} 条 szjilu 记录')) diff --git a/jituan/middleware.py b/jituan/middleware.py new file mode 100644 index 0000000..33fcd61 --- /dev/null +++ b/jituan/middleware.py @@ -0,0 +1,20 @@ +"""请求级俱乐部上下文中间件。""" +from jituan.constants import CLUB_ID_DEFAULT +from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope + + +class ClubContextMiddleware: + """ + 为每个请求注入 club_id / club_scope。 + 旧客户端不传 Header 时 club_id 恒为 xq,不影响现网。 + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + request.club_id = resolve_club_id_from_request(request) + request.club_scope = resolve_club_scope(request) + if not request.club_id: + request.club_id = CLUB_ID_DEFAULT + return self.get_response(request) diff --git a/jituan/migrations/0001_initial.py b/jituan/migrations/0001_initial.py new file mode 100644 index 0000000..95fceec --- /dev/null +++ b/jituan/migrations/0001_initial.py @@ -0,0 +1,183 @@ +# Generated for jituan multi-club platform + +import gvsdsdk.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name='Club', + fields=[ + ('club_id', models.CharField(max_length=16, primary_key=True, serialize=False, verbose_name='俱乐部ID')), + ('name', models.CharField(max_length=64, verbose_name='展示名')), + ('status', models.IntegerField(default=1, verbose_name='1启用0停用')), + ('wx_appid', models.CharField(blank=True, default='', max_length=32)), + ('wx_secret', models.CharField(blank=True, default='', max_length=128)), + ('mch_id', models.CharField(blank=True, default='', max_length=32)), + ('pay_app_id', models.CharField(blank=True, default='', max_length=32)), + ('api_v3_key', models.CharField(blank=True, default='', max_length=128)), + ('cert_serial_no', models.CharField(blank=True, default='', max_length=64)), + ('private_key_path', models.CharField(blank=True, default='', max_length=255)), + ('platform_cert_dir', models.CharField(blank=True, default='', max_length=255)), + ('official_appid', models.CharField(blank=True, default='', max_length=32)), + ('official_secret', models.CharField(blank=True, default='', max_length=128)), + ('official_token', models.CharField(blank=True, default='', max_length=64)), + ('encoding_aes_key', models.CharField(blank=True, default='', max_length=64)), + ('template_id', models.CharField(blank=True, default='', max_length=64)), + ('template_max_per_minute', models.IntegerField(default=950)), + ('h5_domain', models.CharField(blank=True, default='', max_length=128)), + ('oss_prefix', models.CharField(blank=True, default='', max_length=128)), + ('goeasy_appkey', models.CharField(blank=True, default='', max_length=64)), + ('goeasy_secret', models.CharField(blank=True, default='', max_length=128)), + ('config_json', models.JSONField(blank=True, default=dict)), + ('company_uuid', models.BinaryField(blank=True, null=True)), + ('sort_order', models.IntegerField(default=0)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': '俱乐部', + 'db_table': 'club', + }, + ), + migrations.CreateModel( + name='UserWxOpenid', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, max_length=16)), + ('openid', models.CharField(db_index=True, max_length=64)), + ('yonghuid', models.CharField(db_index=True, max_length=7, verbose_name='用户ID')), + ('unionid', models.CharField(blank=True, max_length=64, null=True)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'user_wx_openid', + 'unique_together': {('club_id', 'openid')}, + }, + ), + migrations.CreateModel( + name='AdminAssignment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('yonghuid', models.CharField(db_index=True, max_length=7)), + ('club_id', models.CharField(blank=True, db_index=True, max_length=16, null=True, verbose_name='NULL=集团任职')), + ('role_code', models.CharField(default='CLUB_ADMIN', max_length=50)), + ('data_scope', models.CharField(default='SINGLE_CLUB', max_length=20)), + ('is_primary', models.BooleanField(default=False)), + ('granted_by', models.CharField(blank=True, default='', max_length=7)), + ('status', models.IntegerField(default=1, verbose_name='1有效0停用')), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'admin_assignment', + 'unique_together': {('yonghuid', 'club_id', 'role_code')}, + }, + ), + migrations.CreateModel( + name='AdminAuditLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(blank=True, db_index=True, max_length=16, null=True)), + ('operator_yonghuid', models.CharField(db_index=True, max_length=7)), + ('operator_role_code', models.CharField(blank=True, default='', max_length=50)), + ('target_yonghuid', models.CharField(blank=True, max_length=7, null=True)), + ('action', models.CharField(db_index=True, max_length=64)), + ('resource_type', models.CharField(blank=True, default='', max_length=32)), + ('resource_id', models.CharField(blank=True, default='', max_length=64)), + ('field_name', models.CharField(blank=True, default='', max_length=64)), + ('value_before', models.TextField(blank=True, null=True)), + ('value_after', models.TextField(blank=True, null=True)), + ('request_ip', models.CharField(blank=True, default='', max_length=64)), + ('remark', models.CharField(blank=True, default='', max_length=500)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'admin_audit_log', + 'indexes': [models.Index(fields=['club_id', '-CreateTime'], name='admin_audit_club_time_idx')], + }, + ), + migrations.CreateModel( + name='ClubHuiyuanPrice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, max_length=16)), + ('huiyuan_id', models.CharField(db_index=True, max_length=6)), + ('jiage', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('guanshifc', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('zuzhangfc', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('is_enabled', models.BooleanField(default=True)), + ('bankuai_id', models.IntegerField(blank=True, null=True)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'club_huiyuan_price', + 'unique_together': {('club_id', 'huiyuan_id')}, + }, + ), + migrations.CreateModel( + name='ClubLilubiao', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, max_length=16)), + ('config_type', models.IntegerField(db_index=True, verbose_name='同 lilubiao.fadanpingtai')), + ('lilv', models.DecimalField(decimal_places=4, default=0, max_digits=10)), + ('remark', models.CharField(blank=True, default='', max_length=255)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'club_lilubiao', + 'unique_together': {('club_id', 'config_type')}, + }, + ), + migrations.CreateModel( + name='ClubWithdrawConfig', + fields=[ + ('club_id', models.CharField(max_length=16, primary_key=True, serialize=False)), + ('mode', models.IntegerField(default=1, verbose_name='1自动2手动')), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'club_withdraw_config', + }, + ), + migrations.CreateModel( + name='ClubTixianQuota', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, max_length=16)), + ('leixing', models.IntegerField(db_index=True)), + ('daily_limit', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'club_tixian_quota', + 'unique_together': {('club_id', 'leixing')}, + }, + ), + migrations.CreateModel( + name='ClubFadanFenhongLilv', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, max_length=16)), + ('shenfen', models.IntegerField(db_index=True)), + ('lilv', models.DecimalField(decimal_places=4, default=0, max_digits=10)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'club_fadan_fenhong_lilv', + 'unique_together': {('club_id', 'shenfen')}, + }, + ), + ] diff --git a/jituan/migrations/__init__.py b/jituan/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jituan/models.py b/jituan/models.py new file mode 100644 index 0000000..ad24e95 --- /dev/null +++ b/jituan/models.py @@ -0,0 +1,157 @@ +"""集团多俱乐部数据模型(新建表,不修改 gvsdsdk)。""" +import uuid +from decimal import Decimal + +from django.db import models +from gvsdsdk.model_base import QModel + + +class Club(QModel): + """子公司 / 俱乐部主表。""" + club_id = models.CharField(max_length=16, primary_key=True, verbose_name='俱乐部ID') + name = models.CharField(max_length=64, verbose_name='展示名') + status = models.IntegerField(default=1, verbose_name='1启用0停用') + wx_appid = models.CharField(max_length=32, blank=True, default='') + wx_secret = models.CharField(max_length=128, blank=True, default='') + mch_id = models.CharField(max_length=32, blank=True, default='') + pay_app_id = models.CharField(max_length=32, blank=True, default='') + api_v3_key = models.CharField(max_length=128, blank=True, default='') + cert_serial_no = models.CharField(max_length=64, blank=True, default='') + private_key_path = models.CharField(max_length=255, blank=True, default='') + platform_cert_dir = models.CharField(max_length=255, blank=True, default='') + official_appid = models.CharField(max_length=32, blank=True, default='') + official_secret = models.CharField(max_length=128, blank=True, default='') + official_token = models.CharField(max_length=64, blank=True, default='') + encoding_aes_key = models.CharField(max_length=64, blank=True, default='') + template_id = models.CharField(max_length=64, blank=True, default='') + template_max_per_minute = models.IntegerField(default=950) + h5_domain = models.CharField(max_length=128, blank=True, default='') + oss_prefix = models.CharField(max_length=128, blank=True, default='') + goeasy_appkey = models.CharField(max_length=64, blank=True, default='') + goeasy_secret = models.CharField(max_length=128, blank=True, default='') + config_json = models.JSONField(default=dict, blank=True) + company_uuid = models.BinaryField(max_length=16, null=True, blank=True) + sort_order = models.IntegerField(default=0) + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'club' + verbose_name = '俱乐部' + + +class UserWxOpenid(QModel): + """微信 openid 与俱乐部用户绑定(club_id + openid 唯一)。""" + club_id = models.CharField(max_length=16, db_index=True) + openid = models.CharField(max_length=64, db_index=True) + yonghuid = models.CharField(max_length=7, db_index=True, verbose_name='用户ID') + unionid = models.CharField(max_length=64, null=True, blank=True) + CreateTime = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'user_wx_openid' + unique_together = [['club_id', 'openid']] + + +class AdminAssignment(QModel): + """管理任职:集团或子公司后台权限范围。""" + yonghuid = models.CharField(max_length=7, db_index=True) + club_id = models.CharField(max_length=16, null=True, blank=True, db_index=True, + verbose_name='NULL=集团任职') + role_code = models.CharField(max_length=50, default='CLUB_ADMIN') + data_scope = models.CharField(max_length=20, default='SINGLE_CLUB') + is_primary = models.BooleanField(default=False) + granted_by = models.CharField(max_length=7, blank=True, default='') + status = models.IntegerField(default=1, verbose_name='1有效0停用') + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'admin_assignment' + unique_together = [['yonghuid', 'club_id', 'role_code']] + + +class AdminAuditLog(QModel): + """后台敏感操作审计(改余额等)。""" + club_id = models.CharField(max_length=16, null=True, blank=True, db_index=True) + operator_yonghuid = models.CharField(max_length=7, db_index=True) + operator_role_code = models.CharField(max_length=50, blank=True, default='') + target_yonghuid = models.CharField(max_length=7, null=True, blank=True) + action = models.CharField(max_length=64, db_index=True) + resource_type = models.CharField(max_length=32, blank=True, default='') + resource_id = models.CharField(max_length=64, blank=True, default='') + field_name = models.CharField(max_length=64, blank=True, default='') + value_before = models.TextField(null=True, blank=True) + value_after = models.TextField(null=True, blank=True) + request_ip = models.CharField(max_length=64, blank=True, default='') + remark = models.CharField(max_length=500, blank=True, default='') + CreateTime = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'admin_audit_log' + indexes = [models.Index(fields=['club_id', '-CreateTime'])] + + +class ClubHuiyuanPrice(QModel): + """各俱乐部会员售价/分成(全局 huiyuan_id 定义不变)。""" + club_id = models.CharField(max_length=16, db_index=True) + huiyuan_id = models.CharField(max_length=6, db_index=True) + 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')) + is_enabled = models.BooleanField(default=True) + bankuai_id = models.IntegerField(null=True, blank=True) + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'club_huiyuan_price' + unique_together = [['club_id', 'huiyuan_id']] + + +class ClubLilubiao(QModel): + """各俱乐部分红/手续费配置(对应 lilubiao.fadanpingtai)。""" + club_id = models.CharField(max_length=16, db_index=True) + config_type = models.IntegerField(db_index=True, verbose_name='同 lilubiao.fadanpingtai') + lilv = models.DecimalField(max_digits=10, decimal_places=4, default=Decimal('0.0000')) + remark = models.CharField(max_length=255, blank=True, default='') + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'club_lilubiao' + unique_together = [['club_id', 'config_type']] + + +class ClubWithdrawConfig(QModel): + club_id = models.CharField(max_length=16, primary_key=True) + mode = models.IntegerField(default=1, verbose_name='1自动2手动') + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'club_withdraw_config' + + +class ClubTixianQuota(QModel): + club_id = models.CharField(max_length=16, db_index=True) + leixing = models.IntegerField(db_index=True) + daily_limit = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00')) + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'club_tixian_quota' + unique_together = [['club_id', 'leixing']] + + +class ClubFadanFenhongLilv(QModel): + club_id = models.CharField(max_length=16, db_index=True) + shenfen = models.IntegerField(db_index=True) + lilv = models.DecimalField(max_digits=10, decimal_places=4, default=Decimal('0.0000')) + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'club_fadan_fenhong_lilv' + unique_together = [['club_id', 'shenfen']] diff --git a/jituan/services/__init__.py b/jituan/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jituan/services/admin_audit.py b/jituan/services/admin_audit.py new file mode 100644 index 0000000..8888e14 --- /dev/null +++ b/jituan/services/admin_audit.py @@ -0,0 +1,114 @@ +"""后台操作日志:xiugaijilu 按俱乐部过滤 + admin_audit_log 写入。""" +import logging + +from jituan.constants import DATA_SCOPE_ALL +from jituan.models import AdminAuditLog +from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope +from jituan.services.club_user import get_user_club_id + +logger = logging.getLogger(__name__) + + +def club_id_for_yonghuid(yonghuid): + from users.business_models import User + user = User.query.filter(UserUID=str(yonghuid)).first() + return get_user_club_id(user) + + +def filter_admin_audit_by_request(qs, request): + """集团 ALL 视图看全部;子公司按 club_id 过滤。""" + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return qs + return qs.filter(club_id=resolve_club_id_from_request(request)) + + +def list_admin_audit_logs(request, page=1, page_size=20, **filters): + """分页查询 admin_audit_log。""" + qs = AdminAuditLog.query.all().order_by('-CreateTime') + qs = filter_admin_audit_by_request(qs, request) + + operator = (filters.get('operator_yonghuid') or '').strip() + target = (filters.get('target_yonghuid') or '').strip() + action = (filters.get('action') or '').strip() + keyword = (filters.get('keyword') or '').strip() + + if operator: + qs = qs.filter(operator_yonghuid=operator) + if target: + qs = qs.filter(target_yonghuid=target) + if action: + qs = qs.filter(action=action) + if keyword: + qs = qs.filter(remark__icontains=keyword) + + page = max(1, int(page or 1)) + page_size = min(100, max(1, int(page_size or 20))) + total = qs.count() + start = (page - 1) * page_size + rows = qs[start:start + page_size] + + data_list = [] + for r in rows: + data_list.append({ + 'id': r.id, + 'club_id': r.club_id, + 'operator_yonghuid': r.operator_yonghuid, + 'operator_role_code': r.operator_role_code, + 'target_yonghuid': r.target_yonghuid, + 'action': r.action, + 'resource_type': r.resource_type, + 'resource_id': r.resource_id, + 'field_name': r.field_name, + 'value_before': r.value_before, + 'value_after': r.value_after, + 'remark': r.remark, + 'request_ip': r.request_ip, + 'CreateTime': r.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if r.CreateTime else '', + }) + + return { + 'list': data_list, + 'total': total, + 'page': page, + 'page_size': page_size, + } + + +def filter_xiugaijilu_by_request(qs, request): + """集团 ALL 视图看全部;子公司仅看本 club 用户相关记录。""" + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return qs + club_id = resolve_club_id_from_request(request) + from users.business_models import User + uids = User.query.filter(ClubID=club_id).values_list('UserUID', flat=True) + uid_list = [str(u) for u in uids] + if not uid_list: + return qs.none() + return qs.filter(yonghuid__in=uid_list) + + +def log_admin_audit_from_xiugai( + *, + yonghuid, + xiugaiid, + leixing, + qitashuoming, + club_id=None, + request_ip='', +): + """与 write_xiugai_log 同步写入 admin_audit_log(失败不影响主流程)。""" + try: + cid = club_id or club_id_for_yonghuid(yonghuid) + AdminAuditLog.query.create( + club_id=cid, + operator_yonghuid=str(xiugaiid), + target_yonghuid=str(yonghuid), + action='xiugaijilu', + resource_type='user', + resource_id=str(yonghuid), + field_name=str(leixing), + remark=(qitashuoming or '')[:500], + request_ip=(request_ip or '')[:64], + ) + except Exception as e: + logger.warning('写入 admin_audit_log 失败: %s', e) diff --git a/jituan/services/admin_context.py b/jituan/services/admin_context.py new file mode 100644 index 0000000..4200148 --- /dev/null +++ b/jituan/services/admin_context.py @@ -0,0 +1,102 @@ +"""集团后台管理员俱乐部上下文。""" +from jituan.constants import ( + ADMIN_ROLE_LABELS, + DATA_SCOPE_ALL, + DATA_SCOPE_SINGLE, + CLUB_ID_DEFAULT, +) +from jituan.models import AdminAssignment, Club + +def build_admin_club_context(user): + """ + 根据 admin_assignment + 超管推断后台登录后的俱乐部上下文。 + 返回 dict 供前端存储与请求头使用。 + """ + yonghuid = user.UserUID + is_super = bool(user.IsSuperuser) or user.UserType == 'admin' + + assignments = list( + AdminAssignment.query.filter(yonghuid=yonghuid, status=1).order_by('-is_primary', 'club_id') + ) + + clubs_qs = Club.query.filter(status=1).order_by('sort_order', 'club_id') + all_clubs = [ + {'club_id': c.club_id, 'name': c.name} + for c in clubs_qs + ] + + if is_super and not assignments: + return _pack( + scope=DATA_SCOPE_ALL, + club_id=CLUB_ID_DEFAULT, + is_group_admin=True, + assignments=[], + clubs=all_clubs, + can_switch_club=True, + role_code='GROUP_SUPER_ADMIN', + role_name=ADMIN_ROLE_LABELS['GROUP_SUPER_ADMIN'], + ) + + if not assignments: + return _pack( + scope=DATA_SCOPE_SINGLE, + club_id=CLUB_ID_DEFAULT, + is_group_admin=False, + assignments=[], + clubs=all_clubs, + can_switch_club=len(all_clubs) > 1, + role_code='CLUB_ADMIN', + role_name='子公司客服(默认)', + ) + + primary = next((a for a in assignments if a.is_primary), assignments[0]) + has_group = any(a.club_id is None or a.data_scope == DATA_SCOPE_ALL for a in assignments) + allowed_club_ids = {a.club_id for a in assignments if a.club_id} + + if has_group: + scope = DATA_SCOPE_ALL + club_id = CLUB_ID_DEFAULT + else: + scope = DATA_SCOPE_SINGLE + club_id = primary.club_id or CLUB_ID_DEFAULT + + visible_clubs = all_clubs if has_group else [ + c for c in all_clubs if c['club_id'] in allowed_club_ids + ] + + role_code = primary.role_code or 'CLUB_ADMIN' + return _pack( + scope=scope, + club_id=club_id, + is_group_admin=has_group, + assignments=assignments, + clubs=visible_clubs, + can_switch_club=has_group or len(visible_clubs) > 1, + role_code=role_code, + role_name=ADMIN_ROLE_LABELS.get(role_code, role_code), + ) + + +def _pack(scope, club_id, is_group_admin, assignments, clubs, can_switch_club, + role_code, role_name): + return { + 'scope': scope, + 'club_id': club_id, + 'is_group_admin': is_group_admin, + 'role_code': role_code, + 'role_name': role_name, + 'perm_source': 'gvsdsdk', + 'perm_note': '功能菜单权限仍由 gvsdsdk UserRole→Permission 控制;本表仅管数据范围', + 'assignments': [ + { + 'club_id': a.club_id, + 'role_code': a.role_code, + 'role_name': ADMIN_ROLE_LABELS.get(a.role_code, a.role_code), + 'data_scope': a.data_scope, + 'is_primary': a.is_primary, + } + for a in assignments + ], + 'clubs': clubs, + 'can_switch_club': can_switch_club, + } diff --git a/jituan/services/caiwu_stats.py b/jituan/services/caiwu_stats.py new file mode 100644 index 0000000..06df6e1 --- /dev/null +++ b/jituan/services/caiwu_stats.py @@ -0,0 +1,216 @@ +"""俱乐部维度财务统计(供 /jituan/houtai/caiwu,旧 /houtai/caiwu 不变)。""" +from calendar import monthrange +from datetime import date, datetime, timedelta +from decimal import Decimal + +from django.db.models import Sum + +from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope +from jituan.constants import DATA_SCOPE_ALL, CLUB_ID_DEFAULT + +from config.models import DailyIncomeStat, DailyPayoutStat, Szjilu +from orders.models import Order +from products.models import Czjilu, Huiyuangoumai +from users.models import UserDashou, UserGuanshi, UserShangjia, UserZuzhang + + +def _role_profile_qs(profile_model, request): + """按俱乐部过滤角色扩展表(通过 user.ClubID)。""" + qs = profile_model.query.all() + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return qs + return qs.filter(user__ClubID=resolve_club_id_from_request(request)) + + +def _sum_role_balance(profile_model, balance_field, request): + return float( + _role_profile_qs(profile_model, request).aggregate(total=Sum(balance_field))['total'] or 0.00 + ) + + +def _build_szjilu_payload(request): + """全局收支流水表 szjilu(按俱乐部或集团汇总)。""" + if resolve_club_scope(request) == DATA_SCOPE_ALL: + agg = Szjilu.query.aggregate( + zongshouyi=Sum('TotalIncome'), + zongliushui=Sum('TotalFlow'), + zongzhichu=Sum('TotalExpense'), + jrls=Sum('DailyFlow'), + jrzc=Sum('DailyExpense'), + ) + return { + 'zongliushui': float(agg['zongliushui'] or 0), + 'zongshouyi': float(agg['zongshouyi'] or 0), + 'zongzhichu': float(agg['zongzhichu'] or 0), + 'jrls': float(agg['jrls'] or 0), + 'jrzc': float(agg['jrzc'] or 0), + } + from jituan.services.szjilu_accounting import get_szjilu_snapshot + return get_szjilu_snapshot(resolve_club_id_from_request(request)) + + +def _order_qs(request): + qs = Order.query.all() + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return qs + club_id = resolve_club_id_from_request(request) + if hasattr(Order, 'ClubID'): + return qs.filter(ClubID=club_id) + return qs + + +def _czjilu_qs(request): + qs = Czjilu.query.all() + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return qs + club_id = resolve_club_id_from_request(request) + if hasattr(Czjilu, 'club_id'): + return qs.filter(club_id=club_id) + return qs + + +def _huiyuangoumai_qs(request): + qs = Huiyuangoumai.query.all() + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return qs + club_id = resolve_club_id_from_request(request) + if hasattr(Huiyuangoumai, 'club_id'): + return qs.filter(club_id=club_id) + return qs + + +def _daily_income_qs(request): + qs = DailyIncomeStat.query.all() + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return qs + club_id = resolve_club_id_from_request(request) + if hasattr(DailyIncomeStat, 'club_id'): + return qs.filter(club_id=club_id) + return qs + + +def _daily_payout_qs(request): + qs = DailyPayoutStat.query.all() + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return qs + club_id = resolve_club_id_from_request(request) + if hasattr(DailyPayoutStat, 'club_id'): + return qs.filter(club_id=club_id) + return qs + + +def build_caiwu_payload(request): + """与 CaiwuView 返回结构一致,但按俱乐部过滤订单/充值流水。""" + today = date.today() + today_start = datetime.combine(today, datetime.min.time()) + today_end = today_start + timedelta(days=1) + club_id = resolve_club_id_from_request(request) + + order_qs = _order_qs(request) + cz_qs = _czjilu_qs(request) + hy_qs = _huiyuangoumai_qs(request) + income_qs = _daily_income_qs(request) + payout_qs = _daily_payout_qs(request) + + today_income = income_qs.filter(date=today).first() + today_income_amount = float(today_income.total_amount) if today_income else 0.00 + today_income_count = today_income.total_count if today_income else 0 + + today_payout = payout_qs.filter(date=today).first() + today_payout_amount = float(today_payout.total_amount) if today_payout else 0.00 + today_payout_count = today_payout.total_count if today_payout else 0 + + today_orders = order_qs.filter( + CreateTime__gte=today_start, + CreateTime__lt=today_end, + Status__in=[1, 2, 3, 4, 5, 6, 7, 8], + ) + today_order_count = today_orders.count() + today_order_amount = float(today_orders.aggregate(total=Sum('Amount'))['total'] or 0.00) + + today_completed = order_qs.filter( + CreateTime__gte=today_start, CreateTime__lt=today_end, Status=3, + ) + today_completed_count = today_completed.count() + today_completed_amount = float(today_completed.aggregate(total=Sum('Amount'))['total'] or 0.00) + + today_tuikuan = order_qs.filter( + CreateTime__gte=today_start, CreateTime__lt=today_end, Status=5, + ) + today_tuikuan_count = today_tuikuan.count() + today_tuikuan_amount = float(today_tuikuan.aggregate(total=Sum('Amount'))['total'] or 0.00) + + today_chongzhi = cz_qs.filter( + CreateTime__gte=today_start, CreateTime__lt=today_end, leixing=1, zhuangtai=3, + ) + today_chongzhi_count = today_chongzhi.count() + today_chongzhi_amount = float(today_chongzhi.aggregate(total=Sum('jine'))['total'] or 0.00) + + new_huiyuan_user_ids = hy_qs.filter( + CreateTime__gte=today_start, CreateTime__lt=today_end, + ).values_list('yonghu_id', flat=True).distinct() + today_new_huiyuan = len(list(new_huiyuan_user_ids)) + + today_new_huiyuan_amount = 0.0 + if new_huiyuan_user_ids: + amount_sum = cz_qs.filter( + CreateTime__gte=today_start, + CreateTime__lt=today_end, + yonghuid__in=new_huiyuan_user_ids, + leixing=1, + zhuangtai=3, + ).aggregate(total=Sum('jine')) + today_new_huiyuan_amount = float(amount_sum['total'] or 0.0) + + all_guanshi_yue = _sum_role_balance(UserGuanshi, 'yue', request) + all_dashou_yue = _sum_role_balance(UserDashou, 'yue', request) + all_zuzhang_yue = _sum_role_balance(UserZuzhang, 'ketixian_jine', request) + all_shangjia_yue = _sum_role_balance(UserShangjia, 'yue', request) + + total_income = float(income_qs.aggregate(total=Sum('total_amount'))['total'] or 0.00) + total_payout = float(payout_qs.aggregate(total=Sum('total_amount'))['total'] or 0.00) + platform_profit = round(total_income - total_payout, 2) + + daily_stats = [] + income_list = income_qs.order_by('-date')[:30] + payout_dict = { + str(p.date): float(p.total_amount) + for p in payout_qs.filter(date__gte=today - timedelta(days=30)) + } + for inc in income_list: + d = str(inc.date) + daily_stats.append({ + 'date': d, + 'income': float(inc.total_amount), + 'income_count': inc.total_count, + 'payout': payout_dict.get(d, 0.00), + 'profit': round(float(inc.total_amount) - payout_dict.get(d, 0.00), 2), + }) + + return { + 'club_id': club_id, + 'scope': resolve_club_scope(request), + 'szjilu': _build_szjilu_payload(request), + 'today_income': round(today_income_amount, 2), + 'today_income_count': today_income_count, + 'today_payout': round(today_payout_amount, 2), + 'today_payout_count': today_payout_count, + 'today_order_count': today_order_count, + 'today_order_amount': round(today_order_amount, 2), + 'today_completed_count': today_completed_count, + 'today_completed_amount': round(today_completed_amount, 2), + 'today_tuikuan_count': today_tuikuan_count, + 'today_tuikuan_amount': round(today_tuikuan_amount, 2), + 'today_chongzhi_count': today_chongzhi_count, + 'today_chongzhi_amount': round(today_chongzhi_amount, 2), + 'today_new_huiyuan': today_new_huiyuan, + 'today_new_huiyuan_amount': round(today_new_huiyuan_amount, 2), + 'all_guanshi_yue': round(all_guanshi_yue, 2), + 'all_dashou_yue': round(all_dashou_yue, 2), + 'all_zuzhang_yue': round(all_zuzhang_yue, 2), + 'all_shangjia_yue': round(all_shangjia_yue, 2), + 'total_income': round(total_income, 2), + 'total_payout': round(total_payout, 2), + 'platform_profit': platform_profit, + 'daily_stats': daily_stats, + } diff --git a/jituan/services/club_config.py b/jituan/services/club_config.py new file mode 100644 index 0000000..ae882a7 --- /dev/null +++ b/jituan/services/club_config.py @@ -0,0 +1,53 @@ +"""各俱乐部价格 / 利率覆盖(无覆盖时回落全局 huiyuan / lilubiao)。""" +from decimal import Decimal + +from jituan.constants import CLUB_ID_DEFAULT +from jituan.models import ClubHuiyuanPrice, ClubLilubiao +from orders.models import CommissionRate +from products.models import Huiyuan + + +def get_huiyuan_price(club_id, huiyuan_id, huiyuan_obj=None): + club_id = club_id or CLUB_ID_DEFAULT + row = ClubHuiyuanPrice.query.filter( + club_id=club_id, huiyuan_id=huiyuan_id, is_enabled=True, + ).first() + if row: + return Decimal(str(row.jiage)) + obj = huiyuan_obj or Huiyuan.query.filter(huiyuan_id=huiyuan_id).first() + if obj: + return Decimal(str(obj.jiage)) + return Decimal('0') + + +def get_huiyuan_fenchong(club_id, huiyuan_id, huiyuan_obj=None): + """返回 (guanshifc, zuzhangfc)。""" + club_id = club_id or CLUB_ID_DEFAULT + row = ClubHuiyuanPrice.query.filter(club_id=club_id, huiyuan_id=huiyuan_id).first() + if row: + return Decimal(str(row.guanshifc)), Decimal(str(row.zuzhangfc)) + obj = huiyuan_obj or Huiyuan.query.filter(huiyuan_id=huiyuan_id).first() + if obj: + return Decimal(str(obj.guanshifc)), Decimal(str(getattr(obj, 'zuzhangfc', 0) or 0)) + return Decimal('0'), Decimal('0') + + +def get_commission_rate(club_id, platform_key): + """ + 读取分成利率。platform_key 与 lilubiao.Platform 一致(如 '1','3','13')。 + """ + club_id = club_id or CLUB_ID_DEFAULT + config_type = int(platform_key or 0) + row = ClubLilubiao.query.filter(club_id=club_id, config_type=config_type).first() + if row is not None: + return Decimal(str(row.lilv)) + cr = CommissionRate.query.filter(Platform=str(platform_key)).first() + if cr and cr.Rate is not None: + return Decimal(str(cr.Rate)) + return Decimal('0') + + +def get_commission_rate_object(club_id, platform_key): + """兼容旧代码 CommissionRate.Rate 访问方式。""" + rate = get_commission_rate(club_id, platform_key) + return type('ClubRate', (), {'Rate': rate, 'Platform': str(platform_key)})() diff --git a/jituan/services/club_context.py b/jituan/services/club_context.py new file mode 100644 index 0000000..102f73d --- /dev/null +++ b/jituan/services/club_context.py @@ -0,0 +1,87 @@ +"""俱乐部上下文解析与查询辅助。""" +from jituan.constants import ( + CLUB_ID_DEFAULT, + CLUB_HEADER, + CLUB_SCOPE_HEADER, + DATA_SCOPE_ALL, + DATA_SCOPE_SINGLE, +) +from jituan.models import Club + + +def resolve_club_id_from_request(request): + """ + 从请求解析 club_id。 + 优先级:已注入的 request.club_id > Header X-Club-Id > 默认 xq。 + 旧客户端不传时恒为 xq,与现网行为一致。 + """ + injected = getattr(request, 'club_id', None) + if injected: + return injected + header = request.META.get(f'HTTP_{CLUB_HEADER.upper().replace("-", "_")}', '') + if not header: + header = request.headers.get(CLUB_HEADER, '') if hasattr(request, 'headers') else '' + club_id = (header or '').strip() or CLUB_ID_DEFAULT + return club_id + + +def resolve_club_scope(request): + """解析数据范围:ALL_CLUBS(集团汇总)或 SINGLE_CLUB。""" + scope = getattr(request, 'club_scope', None) + if scope: + return scope + header = request.META.get(f'HTTP_{CLUB_SCOPE_HEADER.upper().replace("-", "_")}', '') + if not header: + header = request.headers.get(CLUB_SCOPE_HEADER, '') if hasattr(request, 'headers') else '' + if (header or '').strip().lower() == 'all': + return DATA_SCOPE_ALL + return DATA_SCOPE_SINGLE + + +def get_active_club(club_id): + try: + club = Club.query.get(club_id=club_id, status=1) + return club + except Club.DoesNotExist: + return None + + +def filter_queryset_by_club(qs, request, club_field='club_id'): + """ + 按请求上下文过滤 QuerySet / FluentQuery。 + 集团 ALL_CLUBS 视图不过滤;子公司 SINGLE_CLUB 按 club_id 过滤。 + """ + scope = resolve_club_scope(request) + club_id = resolve_club_id_from_request(request) + if scope == DATA_SCOPE_ALL: + return qs + if hasattr(qs, 'filter'): + return qs.filter(**{club_field: club_id}) + return qs + + +def club_id_for_write(request): + """写入业务流水时使用的 club_id(集团汇总视图写入仍须指定目标 club)。""" + return resolve_club_id_from_request(request) + + +def orders_for_request(request): + """按俱乐部过滤的订单 QuerySet。""" + from orders.models import Order + return filter_queryset_by_club(Order.query.all(), request, club_field='ClubID') + + +def filter_user_related_by_club(qs, request, user_club_path='user__ClubID'): + """按 User.ClubID 过滤打手/管事/商家等关联查询。""" + scope = resolve_club_scope(request) + if scope == DATA_SCOPE_ALL: + return qs + return qs.filter(**{user_club_path: resolve_club_id_from_request(request)}) + + +def filter_user_qs_by_club(qs, request): + """直接过滤 User 查询集(字段 ClubID)。""" + scope = resolve_club_scope(request) + if scope == DATA_SCOPE_ALL: + return qs + return qs.filter(ClubID=resolve_club_id_from_request(request)) diff --git a/jituan/services/club_penalty.py b/jituan/services/club_penalty.py new file mode 100644 index 0000000..0b4caf1 --- /dev/null +++ b/jituan/services/club_penalty.py @@ -0,0 +1,102 @@ +"""罚单 / 管事分红俱乐部隔离与写入。""" +from rest_framework.response import Response + +from jituan.constants import CLUB_ID_DEFAULT +from jituan.services.club_context import ( + DATA_SCOPE_ALL, + filter_queryset_by_club, + resolve_club_id_from_request, + resolve_club_scope, +) +from jituan.services.club_user import get_user_club_id + + +def resolve_penalty_club_id(order=None, penalized_user_id=None, request=None): + """创建罚单时写入 club_id。""" + if order is not None: + cid = getattr(order, 'ClubID', None) + if cid: + return cid + if penalized_user_id: + from users.business_models import User + u = User.query.filter(UserUID=penalized_user_id).first() + if u: + return get_user_club_id(u) + if request is not None: + return resolve_club_id_from_request(request) + return CLUB_ID_DEFAULT + + +def resolve_gsfenhong_club_id(dingdan_id=None, dashouid=None, order=None, czjilu=None): + """管事分红写入 club_id。""" + if czjilu is not None and getattr(czjilu, 'club_id', None): + return czjilu.club_id + if order is not None: + cid = getattr(order, 'ClubID', None) or getattr(order, 'club_id', None) + if cid: + return cid + if dingdan_id: + from products.models import Czjilu + cz = Czjilu.query.filter(dingdan_id=dingdan_id).first() + if cz and cz.club_id: + return cz.club_id + from orders.models import Order + o = Order.query.filter(OrderID=dingdan_id).first() + if o and getattr(o, 'ClubID', None): + return o.ClubID + if dashouid: + from users.business_models import User + u = User.query.filter(UserUID=dashouid).first() + if u: + return get_user_club_id(u) + return CLUB_ID_DEFAULT + + +def filter_penalty_qs(qs, request): + return filter_queryset_by_club(qs, request, club_field='ClubID') + + +def filter_gsfenhong_qs(qs, request): + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return qs + club_id = resolve_club_id_from_request(request) + return qs.filter(club_id=club_id) + + +def forbid_penalty_out_of_scope(request, penalty): + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return None + club_id = resolve_club_id_from_request(request) + penalty_club = getattr(penalty, 'ClubID', None) or CLUB_ID_DEFAULT + if penalty_club != club_id: + return Response({'code': 403, 'msg': '该罚单不属于当前俱乐部'}) + return None + + +def resolve_penalty_record_club_id(order_id=None, player_id=None): + if order_id: + from orders.models import Order + o = Order.query.filter(OrderID=order_id).first() + if o and getattr(o, 'ClubID', None): + return o.ClubID + if player_id: + from users.business_models import User + u = User.query.filter(UserUID=player_id).first() + if u: + return get_user_club_id(u) + return CLUB_ID_DEFAULT + + +def filter_penalty_record_qs(qs, request): + """积分处罚记录(chufajilu)按俱乐部过滤。""" + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return qs + club_id = resolve_club_id_from_request(request) + if hasattr(qs.model, 'ClubID'): + return qs.filter(ClubID=club_id) + from django.db.models import Q + from orders.models import Order + from users.business_models import User + user_ids = User.query.filter(ClubID=club_id).values_list('UserUID', flat=True) + order_ids = Order.query.filter(ClubID=club_id).values_list('OrderID', flat=True) + return qs.filter(Q(PlayerID__in=user_ids) | Q(OrderID__in=order_ids)) diff --git a/jituan/services/club_resolver.py b/jituan/services/club_resolver.py new file mode 100644 index 0000000..ef11605 --- /dev/null +++ b/jituan/services/club_resolver.py @@ -0,0 +1,79 @@ +"""俱乐部微信配置与 openid 解析。""" +import logging +import requests +from django.conf import settings + +from jituan.constants import CLUB_ID_DEFAULT +from jituan.models import Club + +logger = logging.getLogger(__name__) + + +def resolve_club_by_appid(app_id): + if not app_id: + return None + try: + return Club.query.filter(wx_appid=app_id, status=1).first() + except Exception: + return Club.objects.filter(wx_appid=app_id, status=1).first() + + +def get_club_wx_credentials(club_id): + """优先读 club 表;xq 缺省时回退 settings 全局配置(兼容现网)。""" + club_id = club_id or CLUB_ID_DEFAULT + try: + club = Club.query.get(club_id=club_id) + except Club.DoesNotExist: + club = None + + if club and club.wx_appid and club.wx_secret: + return club.wx_appid, club.wx_secret, club + + if club_id == CLUB_ID_DEFAULT: + return ( + getattr(settings, 'WEIXIN_APPID', ''), + getattr(settings, 'WEIXIN_SECRET', ''), + club, + ) + return '', '', club + + +def jscode2session(code, club_id=None, app_id=None): + """ + 按俱乐部微信配置换取 openid。 + club_id 与 app_id 二选一;app_id 用于从小程序 appId 反查 club。 + """ + club = None + if app_id: + club = resolve_club_by_appid(app_id) + club_id = club.club_id if club else club_id + appid, secret, club = get_club_wx_credentials(club_id) + if not appid or not secret: + return {'errmsg': '俱乐部微信配置未设置'} + + url = 'https://api.weixin.qq.com/sns/jscode2session' + params = { + 'appid': appid, + 'secret': secret, + 'js_code': code, + 'grant_type': 'authorization_code', + } + try: + response = requests.get(url, params=params, timeout=10) + result = response.json() + if 'openid' in result: + return { + 'openid': result['openid'], + 'session_key': result.get('session_key', ''), + 'unionid': result.get('unionid', ''), + 'club_id': club_id or CLUB_ID_DEFAULT, + 'wx_appid': appid, + } + errcode = result.get('errcode', 'unknown') + errmsg = result.get('errmsg', '未知错误') + return {'errmsg': f'[{errcode}]{errmsg}'} + except requests.exceptions.Timeout: + return {'errmsg': '请求微信接口超时'} + except Exception as e: + logger.exception('jscode2session 异常 club_id=%s', club_id) + return {'errmsg': f'请求微信接口异常: {str(e)}'} diff --git a/jituan/services/club_user.py b/jituan/services/club_user.py new file mode 100644 index 0000000..dc8a756 --- /dev/null +++ b/jituan/services/club_user.py @@ -0,0 +1,34 @@ +"""用户所属俱乐部解析。""" +from jituan.constants import CLUB_ID_DEFAULT +from jituan.models import UserWxOpenid + + +def get_user_club_id(user): + """ + 解析用户归属 club(注册/邀请链隔离用)。 + 优先级:User.ClubID → user_wx_openid → 默认 xq。 + """ + if not user: + return CLUB_ID_DEFAULT + + club_id = getattr(user, 'ClubID', None) or getattr(user, 'club_id', None) + if club_id: + return club_id + + uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None) + if uid: + binding = UserWxOpenid.query.filter(yonghuid=uid).order_by('id').first() + if binding: + return binding.club_id + + return CLUB_ID_DEFAULT + + +def ensure_user_club_id(user, club_id): + """登录/注册后确保 User.ClubID 与绑定 club 一致。""" + if not user or not club_id: + return + current = getattr(user, 'ClubID', None) + if current != club_id and hasattr(user, 'ClubID'): + user.ClubID = club_id + user.save(update_fields=['ClubID']) diff --git a/jituan/services/club_user_access.py b/jituan/services/club_user_access.py new file mode 100644 index 0000000..b4e368f --- /dev/null +++ b/jituan/services/club_user_access.py @@ -0,0 +1,27 @@ +"""后台按俱乐部校验用户是否在当前数据范围内。""" +from rest_framework.response import Response + +from jituan.constants import DATA_SCOPE_ALL +from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope + + +def user_belongs_to_request(user, request): + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return True + club_id = resolve_club_id_from_request(request) + user_club = getattr(user, 'ClubID', None) or '' + return user_club == club_id + + +def forbid_if_user_out_of_scope(request, user): + """不属于当前俱乐部时返回 Response,否则返回 None。""" + if user_belongs_to_request(user, request): + return None + return Response({'code': 403, 'msg': '该用户不属于当前俱乐部数据范围'}) + + +def list_response_meta(request): + return { + 'club_id': resolve_club_id_from_request(request), + 'scope': resolve_club_scope(request), + } diff --git a/jituan/services/club_write.py b/jituan/services/club_write.py new file mode 100644 index 0000000..1655f96 --- /dev/null +++ b/jituan/services/club_write.py @@ -0,0 +1,18 @@ +"""业务流水写入时的 club_id 解析。""" +from jituan.constants import CLUB_ID_DEFAULT +from jituan.services.club_context import club_id_for_write +from jituan.services.club_user import get_user_club_id + + +def resolve_club_id_for_write(request=None, user=None, club_id=None): + """ + 写入订单/充值/购买记录时的 club_id。 + 优先级:显式 club_id > request 头 > 用户归属 > xq。 + """ + if club_id: + return club_id + if request is not None: + return club_id_for_write(request) + if user is not None: + return get_user_club_id(user) + return CLUB_ID_DEFAULT diff --git a/jituan/services/display_config.py b/jituan/services/display_config.py new file mode 100644 index 0000000..b62cfa2 --- /dev/null +++ b/jituan/services/display_config.py @@ -0,0 +1,129 @@ +"""轮播 / 公告 / 弹窗按俱乐部读取与写入。""" +from jituan.constants import CLUB_ID_DEFAULT, DATA_SCOPE_ALL +from jituan.services.club_context import filter_queryset_by_club, resolve_club_id_from_request, resolve_club_scope +from rest_framework.response import Response + +# 定稿 B 方案 page_key +LUNBO_PAGE_ORDER_POOL = 'order_pool' +LUNBO_PAGE_ACCEPT_ORDER = 'accept_order' +LUNBO_PAGE_MERCHANT_HOME = 'merchant_home' +LUNBO_PAGE_DASHOU_CENTER = 'dashou_center' + +LUNBO_PAGE_OPTIONS = [ + (LUNBO_PAGE_ORDER_POOL, '抢单池'), + (LUNBO_PAGE_ACCEPT_ORDER, '点单页'), + (LUNBO_PAGE_MERCHANT_HOME, '商家首页'), + (LUNBO_PAGE_DASHOU_CENTER, '打手个人中心背景'), +] + + +def normalize_page_key(page_key, image_type=1): + key = (page_key or '').strip() + if image_type == 2: + return LUNBO_PAGE_DASHOU_CENTER + if key in {LUNBO_PAGE_ORDER_POOL, LUNBO_PAGE_ACCEPT_ORDER, LUNBO_PAGE_MERCHANT_HOME}: + return key + return LUNBO_PAGE_ORDER_POOL + + +def forbid_display_write_in_all_scope(request): + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return Response({'code': 403, 'msg': '请在子公司视图下修改展示配置'}) + return None + + +def filter_popup_page_by_request(qs, request): + return filter_queryset_by_club(qs, request, club_field='club_id') + + +def forbid_popup_page_out_of_scope(request, page): + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return None + club_id = resolve_club_id_from_request(request) + page_club = getattr(page, 'club_id', None) or CLUB_ID_DEFAULT + if page_club != club_id: + return Response({'code': 403, 'msg': '该弹窗配置不属于当前俱乐部'}) + return None + + +def get_gonggao_content(request, notice_type=1): + from config.models import Gonggao + club_id = resolve_club_id_from_request(request) + obj = Gonggao.query.filter(club_id=club_id, NoticeType=notice_type).first() + return obj.Content if obj and obj.Content else '' + + +def get_lunbo_urls(request, page_key=None, image_type=1): + from config.models import Lunbo + club_id = resolve_club_id_from_request(request) + pk = normalize_page_key(page_key, image_type=image_type) + qs = Lunbo.query.filter(club_id=club_id, ImageType=image_type, page_key=pk) + return [row.ImageURL for row in qs.order_by('id') if row.ImageURL] + + +def list_response_meta(request): + from jituan.services.club_user_access import list_response_meta as _meta + return _meta(request) + + +def copy_display_config(template_club_id, new_club_id): + """从模板俱乐部复制轮播/公告/弹窗/图片配置到新俱乐部。""" + from config.models import Gonggao, Lunbo, PopupConfig, PopupImage, PopupPage, Tupianpeizhi + + for row in Lunbo.query.filter(club_id=template_club_id): + Lunbo.query.create( + club_id=new_club_id, + page_key=row.page_key or 'order_pool', + ImageURL=row.ImageURL, + ImageType=row.ImageType, + ) + + for row in Gonggao.query.filter(club_id=template_club_id): + Gonggao.query.create( + club_id=new_club_id, + NoticeType=row.NoticeType, + Content=row.Content, + ) + + for row in Tupianpeizhi.query.filter(club_id=template_club_id): + if Tupianpeizhi.query.filter(club_id=new_club_id, ImageType=row.ImageType).exists(): + continue + Tupianpeizhi.query.create( + club_id=new_club_id, + ImageURL=row.ImageURL, + ImageType=row.ImageType, + AccessCount=0, + ) + + for page in PopupPage.query.filter(club_id=template_club_id).prefetch_related('popups__images'): + new_page = PopupPage.query.create( + club_id=new_club_id, + page_key=page.page_key, + name=page.name, + description=page.description, + is_active=page.is_active, + ignore_user_mute=page.ignore_user_mute, + ) + for popup in page.popups.all(): + new_popup = PopupConfig.query.create( + page=new_page, + popup_id=popup.popup_id, + title=popup.title, + description=popup.description, + strategy_type=popup.strategy_type, + max_count=popup.max_count, + reset_interval=popup.reset_interval, + duration=popup.duration, + force_even_muted=popup.force_even_muted, + sort_order=popup.sort_order, + is_active=popup.is_active, + start_time=popup.start_time, + end_time=popup.end_time, + ) + for img in popup.images.all(): + PopupImage.query.create( + popup_config=new_popup, + image_url=img.image_url, + text=img.text, + sort_order=img.sort_order, + ) diff --git a/jituan/services/finance_detail_stats.py b/jituan/services/finance_detail_stats.py new file mode 100644 index 0000000..46d4597 --- /dev/null +++ b/jituan/services/finance_detail_stats.py @@ -0,0 +1,492 @@ +"""财务子模块统计(订单/会员/其他充值),供 /jituan/houtai/*;旧 /houtai/* 不变。""" +from datetime import date, datetime + +from django.db.models import Count, Q, Sum +from django.db.models.functions import TruncDate, TruncMonth, TruncYear + +from jituan.constants import DATA_SCOPE_ALL +from jituan.models import ClubHuiyuanPrice +from jituan.services.club_context import ( + filter_queryset_by_club, + resolve_club_id_from_request, + resolve_club_scope, +) +from jituan.services.club_penalty import filter_gsfenhong_qs, filter_penalty_qs +from orders.models import Order, Penalty +from products.models import Bankuai, Czjilu, Gsfenhong, Huiyuan, ShangpinLeixing + + +def _meta(request): + return { + 'club_id': resolve_club_id_from_request(request), + 'scope': resolve_club_scope(request), + } + + +def _parse_time_range(granularity, year, month, summary_date): + now = date.today() + if granularity == 'day': + if not year or not month: + return None, '按日统计需要年份和月份' + start_date = date(int(year), int(month), 1) + end_date = date(int(year), int(month) + 1, 1) if int(month) < 12 else date(int(year) + 1, 1, 1) + trunc_func = TruncDate('CreateTime') + if not summary_date: + summary_date = now.strftime('%Y-%m-%d') + summary_q = datetime.strptime(summary_date, '%Y-%m-%d').date() + elif granularity == 'month': + if not year: + return None, '按月统计需要年份' + y = int(year) + start_date = date(y, 1, 1) + end_date = date(y + 1, 1, 1) + trunc_func = TruncMonth('CreateTime') + if not summary_date: + summary_date = now.strftime('%Y-%m') + summary_q = datetime.strptime(summary_date + '-01', '%Y-%m-%d').date() + elif granularity == 'year': + y = int(year or now.year) + start_date = date(y, 1, 1) + end_date = date(y + 1, 1, 1) + trunc_func = TruncYear('CreateTime') + if not summary_date: + summary_date = str(y) + summary_q = date(y, 1, 1) + else: + return None, '无效的颗粒度' + + start_dt = datetime.combine(start_date, datetime.min.time()) + end_dt = datetime.combine(end_date, datetime.min.time()) + return { + 'start_dt': start_dt, + 'end_dt': end_dt, + 'trunc_func': trunc_func, + 'summary_q': summary_q, + 'granularity': granularity, + 'year': int(year) if year is not None else None, + 'month': int(month) if month is not None else None, + }, None + + +def _summary_date_filter(granularity, summary_q, field_prefix='CreateTime'): + if granularity == 'day': + return {f'{field_prefix}__date': summary_q} + if granularity == 'month': + return {f'{field_prefix}__year': summary_q.year, f'{field_prefix}__month': summary_q.month} + return {f'{field_prefix}__year': summary_q.year} + + +def build_order_types_payload(): + types = ShangpinLeixing.query.filter(shenhezhuangtai=1).values('id', 'jieshao') + return list(types) + + +def build_order_finance_payload( + request, + granularity, + year, + month, + type_id=0, + order_source=0, + summary_date=None, +): + parsed, err = _parse_time_range(granularity, year, month, summary_date) + if err: + return None, err + + start_dt = parsed['start_dt'] + end_dt = parsed['end_dt'] + trunc_func = parsed['trunc_func'] + summary_q = parsed['summary_q'] + granularity = parsed['granularity'] + + filters = { + 'CreateTime__gte': start_dt, + 'CreateTime__lt': end_dt, + 'Status__in': [1, 2, 3, 4, 5, 6, 7, 8], + } + if int(type_id or 0) != 0: + filters['ProductTypeID'] = type_id + if int(order_source or 0) in [1, 2]: + filters['Platform'] = int(order_source) + + order_base = filter_queryset_by_club( + Order.query.filter(**filters), request, club_field='ClubID', + ) + qs = order_base.annotate(period=trunc_func).values('period').annotate( + order_count=Count('id'), + order_amount=Sum('Amount'), + success_count=Count('id', filter=Q(Status=3)), + success_amount=Sum('Amount', filter=Q(Status=3)), + refund_count=Count('id', filter=Q(Status=5)), + PlayerCommission_sum=Sum('PlayerCommission', filter=Q(Status=3)), + dianpu_fenhong_sum=Sum('pingtai_kuozhan__ShopIncome', filter=Q(Status=3, Platform=1)), + platform_success_amount=Sum('Amount', filter=Q(Status=3, Platform=1)), + merchant_success_amount=Sum('Amount', filter=Q(Status=3, Platform=2)), + platform_dashou=Sum('PlayerCommission', filter=Q(Status=3, Platform=1)), + merchant_dashou=Sum('PlayerCommission', filter=Q(Status=3, Platform=2)), + ).order_by('period') + + time_series = [] + for item in qs: + period_str = ( + item['period'].strftime('%Y-%m-%d') if granularity == 'day' + else item['period'].strftime('%Y-%m') if granularity == 'month' + else item['period'].strftime('%Y') + ) + platform_profit = (item['platform_success_amount'] or 0) - (item['platform_dashou'] or 0) - (item['dianpu_fenhong_sum'] or 0) + merchant_profit = (item['merchant_success_amount'] or 0) - (item['merchant_dashou'] or 0) + time_series.append({ + 'date': period_str, + 'order_count': item['order_count'], + 'order_amount': float(item['order_amount'] or 0), + 'success_count': item['success_count'], + 'success_amount': float(item['success_amount'] or 0), + 'refund_count': item['refund_count'], + 'dashou_fencheng': float(item['PlayerCommission_sum'] or 0), + 'dianpu_fenhong': float(item['dianpu_fenhong_sum'] or 0), + 'profit': round(float(platform_profit + merchant_profit), 2), + }) + + summary_qs = filter_queryset_by_club(Order.query.filter(**filters), request, club_field='ClubID') + total_order_count = summary_qs.count() + total_order_amount = summary_qs.aggregate(s=Sum('Amount'))['s'] or 0 + total_success_count = summary_qs.filter(Status=3).count() + total_success_amount = summary_qs.filter(Status=3).aggregate(s=Sum('Amount'))['s'] or 0 + total_refund_count = summary_qs.filter(Status=5).count() + total_dashou = summary_qs.filter(Status=3).aggregate(s=Sum('PlayerCommission'))['s'] or 0 + total_dianpu_fenhong = summary_qs.filter(Status=3, Platform=1).aggregate( + s=Sum('pingtai_kuozhan__ShopIncome'), + )['s'] or 0 + platform_amount = summary_qs.filter(Status=3, Platform=1).aggregate(s=Sum('Amount'))['s'] or 0 + merchant_amount = summary_qs.filter(Status=3, Platform=2).aggregate(s=Sum('Amount'))['s'] or 0 + platform_dashou_sum = summary_qs.filter(Status=3, Platform=1).aggregate(s=Sum('PlayerCommission'))['s'] or 0 + merchant_dashou_sum = summary_qs.filter(Status=3, Platform=2).aggregate(s=Sum('PlayerCommission'))['s'] or 0 + profit_platform = platform_amount - platform_dashou_sum - total_dianpu_fenhong + profit_merchant = merchant_amount - merchant_dashou_sum + total_profit = profit_platform + profit_merchant + + data = { + **_meta(request), + 'time_series': time_series, + 'summary': { + 'total_order_count': total_order_count, + 'total_order_amount': round(float(total_order_amount), 2), + 'total_success_count': total_success_count, + 'total_success_amount': round(float(total_success_amount), 2), + 'total_refund_count': total_refund_count, + 'total_PlayerCommission': round(float(total_dashou), 2), + 'total_dianpu_fenhong': round(float(total_dianpu_fenhong), 2), + 'total_profit': round(float(total_profit), 2), + }, + } + return data, None + + +def build_chongzhi_finance_payload(request, granularity, year, month, types=None, summary_date=None): + if types is None: + types = [2, 3, 4, 5, 6] + if isinstance(types, list) and len(types) == 0: + types = [2, 3, 4, 5, 6] + + parsed, err = _parse_time_range(granularity, year, month, summary_date) + if err: + return None, err + + start_dt = parsed['start_dt'] + end_dt = parsed['end_dt'] + trunc_func = parsed['trunc_func'] + summary_q = parsed['summary_q'] + granularity = parsed['granularity'] + + time_series_map = {} + + def ensure_date(ds): + if ds not in time_series_map: + time_series_map[ds] = {} + return time_series_map[ds] + + cz_types = [t for t in types if t in [2, 3, 4, 5]] + if cz_types: + cz_qs = filter_queryset_by_club( + Czjilu.query.filter( + CreateTime__gte=start_dt, + CreateTime__lt=end_dt, + leixing__in=cz_types, + zhuangtai=3, + ), + request, + ).annotate(period=trunc_func).values('period', 'leixing').annotate( + total_count=Count('id'), + total_amount=Sum('jine'), + ).order_by('period', 'leixing') + + for item in cz_qs: + period_str = ( + item['period'].strftime('%Y-%m-%d') if granularity == 'day' + else item['period'].strftime('%Y-%m') if granularity == 'month' + else item['period'].strftime('%Y') + ) + lx = item['leixing'] + amount = float(item['total_amount'] or 0) + count = item['total_count'] + profit = amount if lx == 3 else 0.0 + day_data = ensure_date(period_str) + day_data[str(lx)] = { + 'count': count, + 'amount': amount, + 'profit': profit, + 'fenhong_count': 0, + 'fenhong_amount': 0.0, + } + + if 6 in types: + penalty_qs = filter_penalty_qs( + Penalty.query.filter(CreateTime__gte=start_dt, CreateTime__lt=end_dt, Status=2), + request, + ).annotate(period=trunc_func).values('period').annotate( + penalty_count=Count('id'), + penalty_amount=Sum('FineAmount'), + fenhong_amount=Sum('ApplicantBonusAmount'), + fenhong_count=Count('id', filter=Q(ApplicantBonusAmount__gt=0)), + ).order_by('period') + + for item in penalty_qs: + period_str = ( + item['period'].strftime('%Y-%m-%d') if granularity == 'day' + else item['period'].strftime('%Y-%m') if granularity == 'month' + else item['period'].strftime('%Y') + ) + day_data = ensure_date(period_str) + penalty_amt = float(item['penalty_amount'] or 0) + fenhong_amt = float(item['fenhong_amount'] or 0) + day_data['6'] = { + 'count': item['penalty_count'], + 'amount': penalty_amt, + 'profit': round(penalty_amt - fenhong_amt, 2), + 'fenhong_count': item['fenhong_count'], + 'fenhong_amount': fenhong_amt, + } + + time_series = [] + for period_str in sorted(time_series_map.keys()): + entry = {'date': period_str, 'types': {}} + for type_code in sorted(time_series_map[period_str].keys()): + entry['types'][type_code] = time_series_map[period_str][type_code] + time_series.append(entry) + + summary_result = {'by_type': {}, 'total_count': 0, 'total_amount': 0.0, 'total_profit': 0.0} + + for lx in cz_types: + filter_kwargs = {'leixing': lx, 'zhuangtai': 3} + filter_kwargs.update(_summary_date_filter(granularity, summary_q)) + agg = filter_queryset_by_club(Czjilu.query.filter(**filter_kwargs), request).aggregate( + count=Count('id'), amount=Sum('jine'), + ) + count = agg['count'] or 0 + amount = float(agg['amount'] or 0) + profit = amount if lx == 3 else 0.0 + summary_result['by_type'][str(lx)] = { + 'count': count, + 'amount': round(amount, 2), + 'profit': round(profit, 2), + 'fenhong_count': 0, + 'fenhong_amount': 0.0, + } + summary_result['total_count'] += count + summary_result['total_amount'] += amount + summary_result['total_profit'] += profit + + if 6 in types: + penalty_filter = {'Status': 2} + penalty_filter.update(_summary_date_filter(granularity, summary_q)) + agg = filter_penalty_qs(Penalty.query.filter(**penalty_filter), request).aggregate( + count=Count('id'), + amount=Sum('FineAmount'), + fenhong_amount=Sum('ApplicantBonusAmount'), + fenhong_count=Count('id', filter=Q(ApplicantBonusAmount__gt=0)), + ) + penalty_count = agg['count'] or 0 + penalty_amount = float(agg['amount'] or 0) + fh_amount = float(agg['fenhong_amount'] or 0) + fh_count = agg['fenhong_count'] or 0 + profit = round(penalty_amount - fh_amount, 2) + summary_result['by_type']['6'] = { + 'count': penalty_count, + 'amount': round(penalty_amount, 2), + 'profit': profit, + 'fenhong_count': fh_count, + 'fenhong_amount': round(fh_amount, 2), + } + summary_result['total_count'] += penalty_count + summary_result['total_amount'] += penalty_amount + summary_result['total_profit'] += profit + + summary_result['total_amount'] = round(summary_result['total_amount'], 2) + summary_result['total_profit'] = round(summary_result['total_profit'], 2) + + return { + **_meta(request), + 'time_series': time_series, + 'summary': summary_result, + }, None + + +def build_huiyuan_bankuai_payload(request): + club_id = resolve_club_id_from_request(request) + price_map = {} + if resolve_club_scope(request) != DATA_SCOPE_ALL: + for row in ClubHuiyuanPrice.query.filter(club_id=club_id, is_enabled=True): + price_map[row.huiyuan_id] = { + 'jiage': float(row.jiage or 0), + 'guanshifc': float(row.guanshifc or 0), + 'zuzhangfc': float(row.zuzhangfc or 0), + } + + bankuai_list = [] + for bk in Bankuai.query.all(): + members = [] + for m in Huiyuan.query.filter(bankuai=bk).values('huiyuan_id', 'jieshao', 'bankuai_id'): + item = dict(m) + if m['huiyuan_id'] in price_map: + item['club_price'] = price_map[m['huiyuan_id']] + members.append(item) + bankuai_list.append({ + 'bankuai_id': bk.bankuai_id, + 'mingcheng': bk.mingcheng, + 'members': members, + }) + + return {**_meta(request), 'bankuai_list': bankuai_list} + + +def build_huiyuan_stats_payload( + request, + huiyuan_id, + granularity, + year, + month, + include_guanshi=False, + include_zuzhang=False, + summary_date=None, +): + if not huiyuan_id: + return None, '缺少会员ID' + + parsed, err = _parse_time_range(granularity, year, month, summary_date) + if err: + return None, err + + start_dt = parsed['start_dt'] + end_dt = parsed['end_dt'] + trunc_func = parsed['trunc_func'] + summary_q = parsed['summary_q'] + granularity = parsed['granularity'] + + chongzhi_filter = { + 'huiyuan_id': huiyuan_id, + 'leixing': 1, + 'zhuangtai': 3, + 'CreateTime__gte': start_dt, + 'CreateTime__lt': end_dt, + } + fenhong_filter = { + 'huiyuan_id': huiyuan_id, + 'CreateTime__gte': start_dt, + 'CreateTime__lt': end_dt, + } + + chongzhi_qs = filter_queryset_by_club( + Czjilu.query.filter(**chongzhi_filter), request, + ).annotate(period=trunc_func).values('period').annotate( + chongzhi_count=Count('id'), + chongzhi_amount=Sum('jine'), + ).order_by('period') + + fenhong_qs = None + if include_guanshi or include_zuzhang: + fenhong_agg = {} + if include_guanshi: + fenhong_agg['guanshi_amount'] = Sum('fenhong') + if include_zuzhang: + fenhong_agg['zuzhang_amount'] = Sum('zuzhang_fenhong') + fenhong_qs = filter_gsfenhong_qs( + Gsfenhong.query.filter(**fenhong_filter), request, + ).annotate(period=trunc_func).values('period').annotate(**fenhong_agg).order_by('period') + + data_map = {} + for item in chongzhi_qs: + period_str = ( + item['period'].strftime('%Y-%m-%d') if granularity == 'day' + else item['period'].strftime('%Y-%m') if granularity == 'month' + else item['period'].strftime('%Y') + ) + data_map[period_str] = { + 'chongzhi_count': item['chongzhi_count'], + 'chongzhi_amount': float(item['chongzhi_amount'] or 0), + } + + if fenhong_qs: + for item in fenhong_qs: + period_str = ( + item['period'].strftime('%Y-%m-%d') if granularity == 'day' + else item['period'].strftime('%Y-%m') if granularity == 'month' + else item['period'].strftime('%Y') + ) + if period_str not in data_map: + data_map[period_str] = {'chongzhi_count': 0, 'chongzhi_amount': 0.0} + if include_guanshi: + data_map[period_str]['guanshi_amount'] = float(item.get('guanshi_amount') or 0) + if include_zuzhang: + data_map[period_str]['zuzhang_amount'] = float(item.get('zuzhang_amount') or 0) + + time_series = [] + for period_str, vals in data_map.items(): + guanshi = vals.get('guanshi_amount', 0) if include_guanshi else 0 + zuzhang = vals.get('zuzhang_amount', 0) if include_zuzhang else 0 + vals['shouyi'] = round(vals['chongzhi_amount'] - guanshi - zuzhang, 2) + vals['date'] = period_str + time_series.append(vals) + time_series.sort(key=lambda x: x['date']) + + summary_chongzhi_filter = dict(chongzhi_filter) + summary_chongzhi_filter.pop('CreateTime__gte', None) + summary_chongzhi_filter.pop('CreateTime__lt', None) + summary_chongzhi_filter.update(_summary_date_filter(granularity, summary_q)) + summary_chongzhi = filter_queryset_by_club( + Czjilu.query.filter(**summary_chongzhi_filter), request, + ).aggregate(total_count=Count('id'), total_amount=Sum('jine')) + total_chongzhi_count = summary_chongzhi['total_count'] or 0 + total_chongzhi_amount = float(summary_chongzhi['total_amount'] or 0) + + total_guanshi = 0.0 + total_zuzhang = 0.0 + if include_guanshi or include_zuzhang: + summary_fenhong_filter = dict(fenhong_filter) + summary_fenhong_filter.pop('CreateTime__gte', None) + summary_fenhong_filter.pop('CreateTime__lt', None) + summary_fenhong_filter.update(_summary_date_filter(granularity, summary_q)) + fenhong_agg = {} + if include_guanshi: + fenhong_agg['guanshi_sum'] = Sum('fenhong') + if include_zuzhang: + fenhong_agg['zuzhang_sum'] = Sum('zuzhang_fenhong') + summary_fenhong = filter_gsfenhong_qs( + Gsfenhong.query.filter(**summary_fenhong_filter), request, + ).aggregate(**fenhong_agg) + total_guanshi = float(summary_fenhong.get('guanshi_sum') or 0) + total_zuzhang = float(summary_fenhong.get('zuzhang_sum') or 0) + + total_shouyi = round(total_chongzhi_amount - total_guanshi - total_zuzhang, 2) + + return { + **_meta(request), + 'time_series': time_series, + 'summary': { + 'total_chongzhi_count': total_chongzhi_count, + 'total_chongzhi_amount': total_chongzhi_amount, + 'total_guanshi_fenhong': total_guanshi, + 'total_zuzhang_fenhong': total_zuzhang, + 'total_shouyi': total_shouyi, + }, + }, None diff --git a/jituan/services/invite_guard.py b/jituan/services/invite_guard.py new file mode 100644 index 0000000..468478c --- /dev/null +++ b/jituan/services/invite_guard.py @@ -0,0 +1,24 @@ +"""邀请码 / 邀请链俱乐部隔离。""" +from jituan.services.club_context import resolve_club_id_from_request +from jituan.services.club_user import get_user_club_id + + +def assert_invite_same_club(request, inviter_user, invitee_user=None): + """ + 校验邀请人与被邀请人属于同一俱乐部,且与请求 club 一致。 + 现网全部 xq 时行为与改造前一致。 + 返回 (ok, message) + """ + club_id = resolve_club_id_from_request(request) + inviter_club = get_user_club_id(inviter_user) + if inviter_club != club_id: + return False, '邀请码不属于当前小程序俱乐部' + + if invitee_user is not None: + invitee_club = get_user_club_id(invitee_user) + if invitee_club != club_id: + return False, '您的账号归属与当前小程序不一致,请使用对应俱乐部小程序' + if invitee_club != inviter_club: + return False, '邀请码与您的俱乐部不匹配' + + return True, '' diff --git a/jituan/services/shenhe_club.py b/jituan/services/shenhe_club.py new file mode 100644 index 0000000..81340ca --- /dev/null +++ b/jituan/services/shenhe_club.py @@ -0,0 +1,24 @@ +"""考核模块按俱乐部过滤。""" +from jituan.constants import DATA_SCOPE_ALL +from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope +from users.business_models import User + + +def filter_shenhe_jilu_by_request(qs, request): + """考核记录:按申请人(打手)所属俱乐部过滤。""" + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return qs + club_id = resolve_club_id_from_request(request) + club_uids = list( + User.query.filter(ClubID=club_id).values_list('UserUID', flat=True) + ) + if not club_uids: + return qs.none() + return qs.filter(shenqingren_id__in=club_uids) + + +def yonghu_chenghao_qs_for_request(base_qs, request): + """板块标签用户数统计:按俱乐部过滤打手。""" + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return base_qs + return base_qs.filter(yonghu__ClubID=resolve_club_id_from_request(request)) diff --git a/jituan/services/szjilu_accounting.py b/jituan/services/szjilu_accounting.py new file mode 100644 index 0000000..253e6d0 --- /dev/null +++ b/jituan/services/szjilu_accounting.py @@ -0,0 +1,84 @@ +"""各俱乐部全局收支流水(szjilu)统一记账。""" +from decimal import Decimal + +from django.db import transaction + +from config.models import Szjilu +from jituan.constants import CLUB_ID_DEFAULT + +_ZERO = Decimal('0.00') +_SZJILU_DEFAULTS = { + 'TotalIncome': _ZERO, + 'TotalFlow': _ZERO, + 'TotalExpense': _ZERO, + 'DailyExpense': _ZERO, + 'DailyFlow': _ZERO, +} + + +def normalize_szjilu_club_id(club_id): + return (club_id or '').strip() or CLUB_ID_DEFAULT + + +def _get_szjilu_for_update(club_id): + cid = normalize_szjilu_club_id(club_id) + szjilu, _ = Szjilu.objects.select_for_update().get_or_create( + club_id=cid, + defaults=dict(_SZJILU_DEFAULTS), + ) + return szjilu + + +def apply_szjilu_income(amount, club_id=None): + """平台收入:总收益、总流水、今日流水增加。""" + jine = Decimal(str(amount)) + with transaction.atomic(): + szjilu = _get_szjilu_for_update(club_id) + szjilu.TotalIncome += jine + szjilu.TotalFlow += jine + szjilu.DailyFlow += jine + szjilu.save() + + +def apply_szjilu_expense(amount, club_id=None): + """平台出款/支出:总收益减少,总支出、今日支出增加。""" + jine = Decimal(str(amount)) + with transaction.atomic(): + szjilu = _get_szjilu_for_update(club_id) + szjilu.TotalIncome -= jine + szjilu.TotalExpense += jine + szjilu.DailyExpense += jine + szjilu.save() + + +def get_szjilu_snapshot(club_id=None): + """读取某俱乐部收支汇总;无记录时返回零值。""" + cid = normalize_szjilu_club_id(club_id) + row = Szjilu.query.filter(club_id=cid).first() + if not row: + return { + 'zongliushui': 0.0, + 'zongshouyi': 0.0, + 'zongzhichu': 0.0, + 'jrls': 0.0, + 'jrzc': 0.0, + } + return { + 'zongliushui': float(row.TotalFlow or _ZERO), + 'zongshouyi': float(row.TotalIncome or _ZERO), + 'zongzhichu': float(row.TotalExpense or _ZERO), + 'jrls': float(row.DailyFlow or _ZERO), + 'jrzc': float(row.DailyExpense or _ZERO), + } + + +def reset_all_daily_szjilu(): + """每日清零:所有俱乐部的今日流水/今日支出。""" + updated = 0 + with transaction.atomic(): + for szjilu in Szjilu.objects.select_for_update().all(): + szjilu.DailyExpense = _ZERO + szjilu.DailyFlow = _ZERO + szjilu.save(update_fields=['DailyExpense', 'DailyFlow', 'UpdateTime']) + updated += 1 + return updated diff --git a/jituan/services/szxx_stats.py b/jituan/services/szxx_stats.py new file mode 100644 index 0000000..5685291 --- /dev/null +++ b/jituan/services/szxx_stats.py @@ -0,0 +1,119 @@ +"""每日收支详细统计(供 /jituan/houtai/szxx,旧 /houtai/szxx 不变)。""" +from datetime import date, datetime + +from django.db.models import Sum + +from config.models import DailyIncomeStat, DailyPayoutStat +from jituan.services.club_context import filter_queryset_by_club, resolve_club_id_from_request, resolve_club_scope + + +def build_szxx_payload(request, granularity, year, month, summary_date): + """ + 与 SzxxView 返回结构一致,按俱乐部过滤日收入/日出款统计。 + 返回 (data_dict, error_msg);error_msg 非空时表示参数错误。 + """ + now = date.today() + + if granularity == 'day': + if not year or not month: + return None, '按日统计需要年份和月份' + group_field = 'day' + income_filter = {'year': year, 'month': month} + payout_filter = {'year': year, 'month': month} + if not summary_date: + summary_date = now.strftime('%Y-%m-%d') + summary_parts = summary_date.split('-') + summary_filter = { + 'year': int(summary_parts[0]), + 'month': int(summary_parts[1]), + 'day': int(summary_parts[2]), + } + elif granularity == 'month': + if not year: + return None, '按月统计需要年份' + group_field = 'month' + income_filter = {'year': year} + payout_filter = {'year': year} + if not summary_date: + summary_date = now.strftime('%Y-%m') + summary_parts = summary_date.split('-') + summary_filter = {'year': int(summary_parts[0]), 'month': int(summary_parts[1])} + elif granularity == 'year': + group_field = 'year' + income_filter = {} + payout_filter = {} + if not summary_date: + summary_date = str(now.year) + summary_filter = {'year': int(summary_date)} + else: + return None, '无效的颗粒度' + + income_qs = filter_queryset_by_club( + DailyIncomeStat.query.filter(**income_filter), request, + ).values(group_field).annotate( + total_income=Sum('total_amount'), + total_count=Sum('total_count'), + ).order_by(group_field) + + payout_qs = filter_queryset_by_club( + DailyPayoutStat.query.filter(**payout_filter), request, + ).values(group_field).annotate( + total_payout=Sum('total_amount'), + total_count=Sum('total_count'), + ).order_by(group_field) + + data_map = {} + for item in income_qs: + key = item[group_field] + data_map[key] = { + 'income': float(item['total_income'] or 0), + 'income_count': item['total_count'] or 0, + 'payout': 0.0, + 'payout_count': 0, + } + + for item in payout_qs: + key = item[group_field] + if key not in data_map: + data_map[key] = {'income': 0.0, 'income_count': 0, 'payout': 0.0, 'payout_count': 0} + data_map[key]['payout'] = float(item['total_payout'] or 0) + data_map[key]['payout_count'] = item['total_count'] or 0 + + time_series = [] + for key, vals in data_map.items(): + if granularity == 'day': + date_str = f"{year}-{month:02d}-{key:02d}" + elif granularity == 'month': + date_str = f"{year}-{key:02d}" + else: + date_str = str(key) + profit = round(vals['income'] - vals['payout'], 2) + time_series.append({ + 'date': date_str, + 'income': vals['income'], + 'payout': vals['payout'], + 'profit': profit, + }) + time_series.sort(key=lambda x: x['date']) + + income_summary = filter_queryset_by_club( + DailyIncomeStat.query.filter(**summary_filter), request, + ).aggregate(total_income=Sum('total_amount'), total_count=Sum('total_count')) + payout_summary = filter_queryset_by_club( + DailyPayoutStat.query.filter(**summary_filter), request, + ).aggregate(total_payout=Sum('total_amount'), total_count=Sum('total_count')) + total_income = float(income_summary['total_income'] or 0) + total_payout = float(payout_summary['total_payout'] or 0) + + return { + 'club_id': resolve_club_id_from_request(request), + 'scope': resolve_club_scope(request), + 'time_series': time_series, + 'summary': { + 'total_income': total_income, + 'total_income_count': income_summary['total_count'] or 0, + 'total_payout': total_payout, + 'total_payout_count': payout_summary['total_count'] or 0, + 'total_profit': round(total_income - total_payout, 2), + }, + }, None diff --git a/jituan/services/tixian_club.py b/jituan/services/tixian_club.py new file mode 100644 index 0000000..e8a5895 --- /dev/null +++ b/jituan/services/tixian_club.py @@ -0,0 +1,32 @@ +"""提现流水按俱乐部隔离。""" +from rest_framework.response import Response + +from jituan.constants import DATA_SCOPE_ALL +from jituan.services.club_context import filter_queryset_by_club, resolve_club_id_from_request, resolve_club_scope +from jituan.services.club_user import get_user_club_id +from jituan.services.wechat_pay import club_id_for_tixian_yonghuid + + +def club_id_for_tixian_user(user): + return get_user_club_id(user) + + +def club_id_for_tixian_yonghuid_safe(yonghuid): + return club_id_for_tixian_yonghuid(yonghuid) + + +def filter_tixianjilu_by_request(qs, request): + return filter_queryset_by_club(qs, request, club_field='club_id') + + +def forbid_if_tixian_out_of_scope(request, tixian_record): + """提现记录不在当前俱乐部范围时返回 Response。""" + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return None + club_id = resolve_club_id_from_request(request) + rec_club = getattr(tixian_record, 'club_id', None) or club_id_for_tixian_yonghuid_safe( + tixian_record.yonghuid, + ) + if rec_club != club_id: + return Response({'code': 403, 'msg': '该提现记录不属于当前俱乐部数据范围'}) + return None diff --git a/jituan/services/user_stats.py b/jituan/services/user_stats.py new file mode 100644 index 0000000..cf36b48 --- /dev/null +++ b/jituan/services/user_stats.py @@ -0,0 +1,80 @@ +"""用户管理汇总统计(集团汇总 + 按俱乐部拆分)。""" +from django.utils import timezone + +from jituan.constants import DATA_SCOPE_ALL +from jituan.models import Club +from jituan.services.club_context import ( + filter_queryset_by_club, + resolve_club_id_from_request, + resolve_club_scope, + filter_user_qs_by_club, + filter_user_related_by_club, +) +from products.models import Huiyuangoumai +from users.business_models import User +from users.models import UserDashou, UserGuanshi, UserShangjia, UserZuzhang + + +def _dashou_valid_count(request, dashou_user_ids): + now = timezone.now() + hy_qs = filter_queryset_by_club(Huiyuangoumai.query.all(), request) + return hy_qs.filter( + yonghu_id__in=dashou_user_ids, + huiyuan_zhuangtai=1, + daoqi_time__gt=now, + ).values('yonghu_id').distinct().count() + + +def _counts_for_club_id(club_id): + dashou_qs = UserDashou.query.filter(user__ClubID=club_id) + dashou_ids = dashou_qs.values_list('user__UserUID', flat=True) + now = timezone.now() + dashou_valid = Huiyuangoumai.query.filter( + club_id=club_id, + yonghu_id__in=dashou_ids, + huiyuan_zhuangtai=1, + daoqi_time__gt=now, + ).values('yonghu_id').distinct().count() + shangjia_qs = UserShangjia.query.filter(user__ClubID=club_id) + return { + 'club_id': club_id, + 'dashou_total': dashou_qs.count(), + 'dashou_valid': dashou_valid, + 'guanshi_total': User.query.filter(ClubID=club_id, GuanshiProfile__isnull=False).count(), + 'shangjia_total': shangjia_qs.count(), + 'shangjia_valid': shangjia_qs.filter(zhuangtai=1).count(), + 'zuzhang_total': UserZuzhang.query.filter(user__ClubID=club_id).count(), + 'zuzhang_active': UserZuzhang.query.filter(user__ClubID=club_id, zhuangtai=1).count(), + } + + +def build_user_summary(request): + dashou_qs = filter_user_related_by_club(UserDashou.query.all(), request) + guanshi_qs = filter_user_qs_by_club( + User.query.filter(GuanshiProfile__isnull=False), request, + ) + shangjia_qs = filter_user_related_by_club(UserShangjia.query.all(), request) + zuzhang_qs = filter_user_related_by_club(UserZuzhang.query.all(), request) + + dashou_ids = dashou_qs.values_list('user__UserUID', flat=True) + result = { + 'club_id': resolve_club_id_from_request(request), + 'scope': resolve_club_scope(request), + 'dashou_total': dashou_qs.count(), + 'dashou_valid': _dashou_valid_count(request, dashou_ids), + 'guanshi_total': guanshi_qs.count(), + 'shangjia_total': shangjia_qs.count(), + 'shangjia_valid': shangjia_qs.filter(zhuangtai=1).count(), + 'zuzhang_total': zuzhang_qs.count(), + 'zuzhang_active': zuzhang_qs.filter(zhuangtai=1).count(), + } + + if resolve_club_scope(request) == DATA_SCOPE_ALL: + by_club = [] + for club in Club.query.filter(status=1).order_by('sort_order', 'club_id'): + row = _counts_for_club_id(club.club_id) + row['name'] = club.name + by_club.append(row) + result['by_club'] = by_club + + return result diff --git a/jituan/services/wechat_login.py b/jituan/services/wechat_login.py new file mode 100644 index 0000000..8bf937a --- /dev/null +++ b/jituan/services/wechat_login.py @@ -0,0 +1,209 @@ +"""俱乐部微信登录(多 openid 绑定,不修改旧 wechatlogin 逻辑)。""" +import logging +import random +import time +import uuid + +from django.db import transaction +from django.utils import timezone +from rest_framework_simplejwt.tokens import RefreshToken + +from jituan.constants import CLUB_ID_DEFAULT +from jituan.models import UserWxOpenid +from jituan.services.club_resolver import jscode2session, resolve_club_by_appid +from users.business_models import User +from users.models import UserBoss, UserDashou, UserGuanshi, UserShangjia, UserZuzhang, UserShenheguan +from gvsdsdk.models import Role + +logger = logging.getLogger(__name__) + + +def _generate_user_uid(): + for _ in range(10): + timestamp_part = str(int(time.time()))[-5:].zfill(5) + random_part = str(random.randint(0, 99)).zfill(2) + user_id = timestamp_part + random_part + if len(user_id) == 7 and user_id.isdigit(): + if not User.query.filter(UserUID=user_id).exists(): + return user_id + raise RuntimeError('生成用户ID失败') + + +def _ensure_boss_profile(user): + try: + UserBoss.query.get(user=user) + except UserBoss.DoesNotExist: + UserBoss.query.create(user=user, nickname='板板大人') + + +def _ensure_consumer_role(user): + from users.business_models import UserRole as BizUserRole + normal_role = Role.objects.filter(RoleName='消费者').first() + if not normal_role: + return + exists = BizUserRole.objects.filter(UserUUID=user.UserUUID, RoleUUID=normal_role.RoleUUID).exists() + if not exists: + BizUserRole.objects.create( + UserRoleUUID=uuid.uuid4().bytes, + UserUUID=user.UserUUID, + RoleUUID=normal_role.RoleUUID, + CompanyUUID=uuid.UUID('b0000000-0000-0000-0000-000000000001').bytes, + ) + + +def _collect_role_status(user): + dashou_status = None + shangjia_status = None + guanshi_status = None + zuzhang_status = None + kaoheguan_status = 0 + boss_nickname = '微信用户' + + try: + boss = UserBoss.query.get(user=user) + boss_nickname = boss.nickname or '微信用户' + except UserBoss.DoesNotExist: + pass + + try: + UserDashou.query.get(user=user) + dashou_status = 1 + except UserDashou.DoesNotExist: + dashou_status = None + + try: + UserShangjia.query.get(user=user) + shangjia_status = 1 + except UserShangjia.DoesNotExist: + shangjia_status = None + + try: + UserGuanshi.query.get(user=user) + guanshi_status = 1 + except UserGuanshi.DoesNotExist: + guanshi_status = None + + try: + UserZuzhang.query.get(user=user) + zuzhang_status = 1 + except UserZuzhang.DoesNotExist: + zuzhang_status = None + + try: + kg = UserShenheguan.query.get(user=user) + kaoheguan_status = 1 if kg.zhuangtai == 1 else 0 + except UserShenheguan.DoesNotExist: + kaoheguan_status = 0 + + return { + 'dashou_status': dashou_status, + 'shangjia_status': shangjia_status, + 'guanshi_status': guanshi_status, + 'zuzhang_status': zuzhang_status, + 'kaoheguan_status': kaoheguan_status, + 'boss_nickname': boss_nickname, + } + + +def login_or_register_by_club(code, club_id=None, app_id=None, client_ip=''): + """ + 俱乐部微信登录核心逻辑。 + - 通过 user_wx_openid(club_id, openid) 定位用户 + - xq 俱乐部兼容:若绑定不存在但 User.OpenID 已存在,则自动建绑定(不新建用户) + """ + wx_data = jscode2session(code, club_id=club_id, app_id=app_id) + if not wx_data or 'openid' not in wx_data: + return None, wx_data.get('errmsg', '微信授权失败') + + openid = wx_data['openid'] + unionid = wx_data.get('unionid', '') + resolved_club_id = wx_data.get('club_id') or club_id or CLUB_ID_DEFAULT + + with transaction.atomic(): + binding = UserWxOpenid.query.filter( + club_id=resolved_club_id, openid=openid + ).first() + + user = None + created = False + + if binding: + user = User.query.filter(UserUID=binding.yonghuid).first() + if not user: + user = User.objects.filter(UserUID=binding.yonghuid).first() + + if not user and resolved_club_id == CLUB_ID_DEFAULT: + user = User.query.filter(OpenID=openid).first() + if not user: + user = User.objects.filter(OpenID=openid).first() + if user: + UserWxOpenid.query.create( + club_id=resolved_club_id, + openid=openid, + yonghuid=user.UserUID, + unionid=unionid or user.UnionID, + ) + + if not user: + user_uuid = uuid.uuid4().bytes + new_uid = _generate_user_uid() + user = User( + UserUUID=user_uuid, + UserUID=new_uid, + UserName=openid, + OpenID=openid if resolved_club_id == CLUB_ID_DEFAULT else None, + UnionID=unionid or None, + ) + user.save() + created = True + _ensure_boss_profile(user) + _ensure_consumer_role(user) + UserWxOpenid.query.create( + club_id=resolved_club_id, + openid=openid, + yonghuid=user.UserUID, + unionid=unionid, + ) + + if unionid and unionid != (user.UnionID or ''): + user.UnionID = unionid + if client_ip: + user.IP = client_ip + user.UserLastLoginDate = timezone.now() + user.save() + + from jituan.services.club_user import ensure_user_club_id + ensure_user_club_id(user, resolved_club_id) + + roles = _collect_role_status(user) + refresh = RefreshToken.for_user(user) + token = str(refresh.access_token) + + from merchant_ops.services.authz import staff_fields_for_login + from users.views import WechatMiniProgramLoginView + staff_fields = staff_fields_for_login(user) + + login_view = WechatMiniProgramLoginView() + order_counts = login_view.jisuanOrderShuliang(user.UserUID) + group_info = login_view.huoquQunPeizhi() + + data = { + 'token': token, + 'nicheng': roles['boss_nickname'], + 'uid': user.UserUID, + 'touxiang': user.Avatar or '', + 'shangjiastatus': roles['shangjia_status'], + 'dashoustatus': roles['dashou_status'], + 'guanshistatus': roles['guanshi_status'], + 'zuzhangstatus': roles['zuzhang_status'], + 'kaoheguanstatus': roles['kaoheguan_status'], + 'dingdantiaoshu': order_counts, + 'dashouqun': group_info.get('dashouqun', ''), + 'dashouqunid': group_info.get('dashouqunid', ''), + 'guanshiqun': group_info.get('guanshiqun', ''), + 'guanshiqunid': group_info.get('guanshiqunid', ''), + 'club_id': resolved_club_id, + 'is_new_user': created, + **staff_fields, + } + return data, None diff --git a/jituan/services/wechat_pay.py b/jituan/services/wechat_pay.py new file mode 100644 index 0000000..7ec789c --- /dev/null +++ b/jituan/services/wechat_pay.py @@ -0,0 +1,160 @@ +"""各俱乐部微信支付 V2(MD5)配置与验签。""" +import hashlib +import logging + +from django.conf import settings + +from jituan.constants import CLUB_ID_DEFAULT +from jituan.models import Club + +logger = logging.getLogger(__name__) + + +def get_wechat_v2_config(club_id=None): + """ + 小程序 JSAPI 支付 V2 配置。 + 多 club 时读 club 表;缺省或 xq 回落 settings(现网不变)。 + """ + cid = (club_id or '').strip() or CLUB_ID_DEFAULT + fallback = { + 'appid': getattr(settings, 'WEIXIN_APPID', ''), + 'mch_id': getattr(settings, 'WEIXIN_MCHID', ''), + 'key': getattr(settings, 'WEIXIN_SHANGHUMIYAO', ''), + 'club_id': CLUB_ID_DEFAULT, + } + if cid == CLUB_ID_DEFAULT: + return fallback + + try: + club = Club.query.get(club_id=cid) + except Club.DoesNotExist: + logger.warning('club %s 不存在,回落全局支付配置', cid) + return fallback + + cfg_json = club.config_json or {} + mch_key = ( + cfg_json.get('mch_key') + or cfg_json.get('shanghumiyao') + or cfg_json.get('WEIXIN_SHANGHUMIYAO') + or fallback['key'] + ) + return { + 'appid': club.pay_app_id or club.wx_appid or fallback['appid'], + 'mch_id': club.mch_id or fallback['mch_id'], + 'key': mch_key, + 'club_id': cid, + } + + +def verify_wechat_v2_signature(xml_data, club_id=None): + """验证微信 V2 支付回调 MD5 签名。""" + try: + import defusedxml.ElementTree as ET + root = ET.fromstring(xml_data) + sign_node = root.find('sign') + if sign_node is None or not sign_node.text: + return False + sign = sign_node.text + data = {child.tag: child.text for child in root if child.tag != 'sign'} + string_a = '&'.join([f'{k}={data[k]}' for k in sorted(data.keys()) if data[k]]) + pay_cfg = get_wechat_v2_config(club_id) + calc = hashlib.md5(f'{string_a}&key={pay_cfg["key"]}'.encode('utf-8')).hexdigest().upper() + if calc == sign: + return True + # 兼容:子 club 未配密钥时尝试全局密钥 + global_key = getattr(settings, 'WEIXIN_SHANGHUMIYAO', '') + if global_key and global_key != pay_cfg['key']: + calc2 = hashlib.md5(f'{string_a}&key={global_key}'.encode('utf-8')).hexdigest().upper() + return calc2 == sign + return False + except Exception as e: + logger.error('微信验签异常: %s', e) + return False + + +def club_id_from_czjilu(dingdan_id): + from products.models import Czjilu + try: + row = Czjilu.query.filter(dingdan_id=dingdan_id).first() + if row and getattr(row, 'club_id', None): + return row.club_id + except Exception: + pass + return CLUB_ID_DEFAULT + + +def club_id_from_order(order_id): + from orders.models import Order + try: + row = Order.query.filter(OrderID=order_id).first() + if row and getattr(row, 'ClubID', None): + return row.ClubID + except Exception: + pass + return CLUB_ID_DEFAULT + + +def get_wechat_v3_config(club_id=None): + """ + 微信 V3(转账/查单)配置。 + 多 club 读 club 表;缺省或 xq 回落 settings.WECHAT_PAY_V3_CONFIG。 + """ + cid = (club_id or '').strip() or CLUB_ID_DEFAULT + wx = dict(getattr(settings, 'WECHAT_PAY_V3_CONFIG', {}) or {}) + fallback = { + 'APPID': wx.get('APPID', ''), + 'MCHID': wx.get('MCHID', ''), + 'PRIVATE_KEY_PATH': wx.get('PRIVATE_KEY_PATH', ''), + 'CERT_SERIAL_NO': wx.get('CERT_SERIAL_NO', ''), + 'API_V3_KEY': wx.get('API_V3_KEY', ''), + 'PLATFORM_CERT_DIR': wx.get('PLATFORM_CERT_DIR', ''), + 'TRANSFER_SCENE_ID': wx.get('TRANSFER_SCENE_ID', '1005'), + 'club_id': CLUB_ID_DEFAULT, + } + if cid == CLUB_ID_DEFAULT: + return fallback + + try: + club = Club.query.get(club_id=cid) + except Club.DoesNotExist: + logger.warning('club %s 不存在,回落全局 V3 支付配置', cid) + return fallback + + cfg_json = club.config_json or {} + return { + 'APPID': club.pay_app_id or club.wx_appid or fallback['APPID'], + 'MCHID': club.mch_id or fallback['MCHID'], + 'PRIVATE_KEY_PATH': ( + club.private_key_path + or cfg_json.get('private_key_path') + or fallback['PRIVATE_KEY_PATH'] + ), + 'CERT_SERIAL_NO': ( + club.cert_serial_no + or cfg_json.get('cert_serial_no') + or fallback['CERT_SERIAL_NO'] + ), + 'API_V3_KEY': ( + club.api_v3_key + or cfg_json.get('api_v3_key') + or fallback['API_V3_KEY'] + ), + 'PLATFORM_CERT_DIR': ( + club.platform_cert_dir + or cfg_json.get('platform_cert_dir') + or fallback['PLATFORM_CERT_DIR'] + ), + 'TRANSFER_SCENE_ID': ( + cfg_json.get('transfer_scene_id') + or cfg_json.get('TRANSFER_SCENE_ID') + or fallback['TRANSFER_SCENE_ID'] + ), + 'club_id': cid, + } + + +def club_id_for_tixian_yonghuid(yonghuid): + from jituan.services.club_user import get_user_club_id + from users.business_models import User + user = User.query.filter(UserUID=yonghuid).first() + return get_user_club_id(user) diff --git a/jituan/services/withdraw_config.py b/jituan/services/withdraw_config.py new file mode 100644 index 0000000..16afacf --- /dev/null +++ b/jituan/services/withdraw_config.py @@ -0,0 +1,267 @@ +"""提现设置与运行时:按俱乐部读 club_lilubiao / club_tixian_quota / club_withdraw_config。""" +from datetime import date +from decimal import Decimal + +from django.db import transaction +from rest_framework.response import Response + +from backend.models import WithdrawalDailyStats +from config.models import TixianQuotaDefault +from jituan.constants import CLUB_ID_DEFAULT, DATA_SCOPE_ALL +from jituan.models import ClubLilubiao, ClubTixianQuota, ClubWithdrawConfig +from jituan.services.club_config import get_commission_rate +from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope + +# 提现手续费率 → lilubiao config_type / CommissionRate Platform +LEIXING_RATE_KEY = { + 1: '5', # 打手佣金 + 2: '6', # 管事分红 + 3: '8', # 组长分红 + 4: '9', # 考核官 + 5: '11', # 打手押金 + 6: '10', # 商家余额 +} + +# 提现身份 leixing → 平台日总限额配置 leixing(club_tixian_quota / TixianQuotaDefault UserType) +WITHDRAW_TOTAL_QUOTA_LEIXING = {1: 4, 2: 5, 3: 6, 4: 7, 5: 8, 6: 9} + + +def _normalize_club_id(club_id): + return (club_id or '').strip() or CLUB_ID_DEFAULT + + +def _club_id(request): + return resolve_club_id_from_request(request) + + +def _get_quota(club_id, leixing, default=20.0): + club_id = _normalize_club_id(club_id) + row = ClubTixianQuota.query.filter(club_id=club_id, leixing=leixing).first() + if row is not None: + return float(row.daily_limit) + obj = TixianQuotaDefault.query.filter(UserType=leixing).first() + return float(obj.default_quota) if obj else default + + +def get_quota_decimal(club_id, leixing, default=20.0): + return Decimal(str(_get_quota(club_id, leixing, default))) + + +def _set_quota(club_id, leixing, quota): + row, created = ClubTixianQuota.query.get_or_create( + club_id=_normalize_club_id(club_id), + leixing=leixing, + defaults={'daily_limit': quota}, + ) + if not created: + row.daily_limit = quota + row.save(update_fields=['daily_limit', 'UpdateTime']) + + +def _set_lilv(club_id, config_type, rate): + ct = int(config_type) + row, created = ClubLilubiao.query.get_or_create( + club_id=_normalize_club_id(club_id), + config_type=ct, + defaults={'lilv': rate, 'remark': ''}, + ) + if not created: + row.lilv = rate + row.save(update_fields=['lilv', 'UpdateTime']) + + +def get_tixian_feilv(club_id, leixing): + """读取提现手续费率(各俱乐部可不同)。""" + rate_key = LEIXING_RATE_KEY.get(leixing) + if not rate_key: + raise ValueError('无效的提现类型') + return get_commission_rate(_normalize_club_id(club_id), rate_key) + + +def get_withdraw_mode(club_id): + club_id = _normalize_club_id(club_id) + try: + return ClubWithdrawConfig.query.get(club_id=club_id).mode + except ClubWithdrawConfig.DoesNotExist: + return 2 + + +def get_personal_daily_quota(club_id, leixing, profile): + """打手/管事/组长个人每日限额:用户额外限额优先,否则读俱乐部配置。""" + if leixing == 1: + if profile.kaioi_ewai_xiane and profile.ewai_xiane > 0: + return Decimal(str(profile.ewai_xiane)) + return get_quota_decimal(club_id, 1) + if leixing == 2: + if profile.kaioi_ewai_xiane and profile.ewai_xiane > 0: + return Decimal(str(profile.ewai_xiane)) + return get_quota_decimal(club_id, 2) + if leixing == 3: + default = get_quota_decimal(club_id, 3, default=0.0) + if profile.kaioi_ewai_tixian and profile.ewai_tixian_xiane > 0: + return Decimal(str(profile.ewai_tixian_xiane)) + return default + raise ValueError('无效的个人限额身份') + + +def get_platform_daily_limit(club_id, leixing): + quota_leixing = WITHDRAW_TOTAL_QUOTA_LEIXING.get(leixing) + if not quota_leixing: + return Decimal('0.00') + return get_quota_decimal(club_id, quota_leixing, default=0.0) + + +def get_platform_daily_total(club_id, leixing, today=None): + """平台今日已提现总额(到账金额口径,按俱乐部)。""" + club_id = _normalize_club_id(club_id) + today = today or date.today() + rec = WithdrawalDailyStats.query.filter( + club_id=club_id, Date=today, WithdrawalType=leixing, + ).first() + if rec: + return Decimal(str(rec.total_amount)) + return Decimal('0.00') + + +def get_or_create_platform_daily_stat(club_id, leixing, today=None): + """在 transaction.atomic 内获取并锁定平台日统计行。""" + club_id = _normalize_club_id(club_id) + today = today or date.today() + stat, _ = WithdrawalDailyStats.objects.select_for_update().get_or_create( + club_id=club_id, + Date=today, + WithdrawalType=leixing, + defaults={'total_amount': Decimal('0.00')}, + ) + return stat + + +def build_tixian_asset_rates(club_id): + club_id = _normalize_club_id(club_id) + return { + 'dashou_rate': float(get_commission_rate(club_id, '5')), + 'dashou_yajin_rate': float(get_commission_rate(club_id, '11')), + 'shangjia_rate': float(get_commission_rate(club_id, '10')), + 'guanshi_rate': float(get_commission_rate(club_id, '6')), + 'zuzhang_rate': float(get_commission_rate(club_id, '8')), + 'kaoheguan_rate': float(get_commission_rate(club_id, '9')), + } + + +def build_withdraw_settings_payload(request): + club_id = _club_id(request) + rate_map = {} + for leixing, code in [(1, '5'), (2, '6'), (3, '8'), (4, '9'), (5, '11'), (6, '10')]: + rate_map[leixing] = float(get_commission_rate(club_id, code)) + + order_rate_map = {} + for leixing, code in [(1, '1'), (3, '3'), (12, '12'), (13, '13')]: + order_rate_map[leixing] = float(get_commission_rate(club_id, code)) + + quota_map = {leixing: _get_quota(club_id, leixing) for leixing in [1, 2, 3]} + + total_limit_map = {} + for withdraw_leixing, quota_leixing in [(1, 4), (2, 5), (3, 6), (4, 7), (5, 8), (6, 9)]: + total_limit_map[withdraw_leixing] = _get_quota(club_id, quota_leixing, default=0.0) + + today = date.today() + today_totals = {i: 0.0 for i in range(1, 7)} + for rec in WithdrawalDailyStats.query.filter(club_id=club_id, Date=today): + if rec.WithdrawalType in today_totals: + today_totals[rec.WithdrawalType] = float(rec.total_amount) + + return { + 'club_id': club_id, + 'scope': resolve_club_scope(request), + 'withdraw_mode': get_withdraw_mode(club_id), + 'rates': rate_map, + 'order_rates': order_rate_map, + 'quotas': quota_map, + 'total_limits': total_limit_map, + 'today_totals': today_totals, + } + + +def apply_withdraw_settings_update(request, permissions): + if resolve_club_scope(request) == DATA_SCOPE_ALL: + return Response({'code': 403, 'msg': '请在子公司视图下修改提现设置'}) + + club_id = _club_id(request) + rates = request.data.get('rates', {}) + order_rates = request.data.get('order_rates', request.data.get('commission_rates', {})) + quotas = request.data.get('quotas', {}) + total_limits = request.data.get('total_limits', {}) + + role_perms = {1: '5500a', 2: '5500b', 3: '5500c'} + rate_code_map = {1: '5', 2: '6', 3: '8', 4: '9', 5: '11', 6: '10'} + order_rate_code_map = {1: '1', 3: '3', 12: '12', 13: '13'} + total_code_map = {1: 4, 2: 5, 3: 6, 4: 7, 5: 8, 6: 9} + + def _rate_key_present(data, role): + return (str(role) in data and data[str(role)] is not None) or \ + (role in data and data[role] is not None) + + with transaction.atomic(): + for role in [1, 2, 3]: + perm_needed = role_perms[role] + if perm_needed not in permissions: + if _rate_key_present(rates, role) or \ + (str(role) in quotas and quotas[str(role)] is not None) or \ + (role in quotas and quotas.get(role) is not None) or \ + (str(role) in total_limits and total_limits[str(role)] is not None) or \ + (role in total_limits and total_limits.get(role) is not None): + return Response({'code': 403, 'msg': f'无权限修改{["","打手","管事","组长"][role]}相关设置(需要{perm_needed})'}) + + extra_role_names = {4: '考核官', 5: '打手押金', 6: '商家余额'} + for role in [4, 5, 6]: + if _rate_key_present(rates, role) and not any(p in permissions for p in ('5500a', '5500b', '5500c')): + return Response({'code': 403, 'msg': f'无权限修改{extra_role_names[role]}提现手续费率'}) + if (str(role) in total_limits and total_limits[str(role)] is not None) or \ + (role in total_limits and total_limits.get(role) is not None): + if not any(p in permissions for p in ('5500a', '5500b', '5500c')): + return Response({'code': 403, 'msg': f'无权限修改{extra_role_names[role]}总限额'}) + + for role in [1, 3, 12, 13]: + if _rate_key_present(order_rates, role) and not any(p in permissions for p in ('5500a', '5500b', '5500c')): + order_names = { + 1: '平台订单打手分红', 3: '商家订单打手分红', + 12: '押金管事分红', 13: '管事订单分红', + } + return Response({'code': 403, 'msg': f'无权限修改{order_names[role]}费率'}) + + for role in [1, 2, 3, 4, 5, 6]: + val = rates.get(str(role), rates.get(role)) + if val is not None: + _set_lilv(club_id, rate_code_map[role], Decimal(str(val))) + + for role in [1, 3, 12, 13]: + val = order_rates.get(str(role), order_rates.get(role)) + if val is not None: + _set_lilv(club_id, order_rate_code_map[role], Decimal(str(val))) + + for role in [1, 2, 3]: + if str(role) in quotas: + val = quotas[str(role)] + if val is not None: + _set_quota(club_id, role, Decimal(str(val))) + elif role in quotas and quotas.get(role) is not None: + _set_quota(club_id, role, Decimal(str(quotas[role]))) + + for role in [1, 2, 3, 4, 5, 6]: + val = total_limits.get(str(role), total_limits.get(role)) + if val is not None: + _set_quota(club_id, total_code_map[role], Decimal(str(val))) + + withdraw_mode = request.data.get('withdraw_mode') + if withdraw_mode is not None: + if not any(p in permissions for p in ('5500a', '5500b', '5500c')): + return Response({'code': 403, 'msg': '无权限修改提现模式'}) + row, created = ClubWithdrawConfig.query.get_or_create( + club_id=club_id, + defaults={'mode': int(withdraw_mode)}, + ) + if not created: + row.mode = int(withdraw_mode) + row.save(update_fields=['mode', 'UpdateTime']) + + return Response({'code': 0, 'msg': '设置修改成功'}) diff --git a/jituan/sql/phase1_add_club_id.sql b/jituan/sql/phase1_add_club_id.sql new file mode 100644 index 0000000..802ae1f --- /dev/null +++ b/jituan/sql/phase1_add_club_id.sql @@ -0,0 +1,130 @@ +-- 集团多俱乐部:业务表 club_id 扩展(默认 xq,现网数据自动归属星阙) +-- 执行前请备份数据库。可重复执行(列已存在则跳过)。 + +-- User 表俱乐部归属 +SET @exist := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'User' AND COLUMN_NAME = 'ClubID' +); +SET @sql := IF(@exist = 0, + 'ALTER TABLE `User` ADD COLUMN `ClubID` varchar(16) NOT NULL DEFAULT ''xq'' COMMENT ''所属俱乐部''', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 订单 +SET @exist := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dingdan' AND COLUMN_NAME = 'club_id' +); +SET @sql := IF(@exist = 0, + 'ALTER TABLE `dingdan` ADD COLUMN `club_id` varchar(16) NOT NULL DEFAULT ''xq'', ADD INDEX `idx_dingdan_club` (`club_id`)', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 充值流水 +SET @exist := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'czjilu' AND COLUMN_NAME = 'club_id' +); +SET @sql := IF(@exist = 0, + 'ALTER TABLE `czjilu` ADD COLUMN `club_id` varchar(16) NOT NULL DEFAULT ''xq'', ADD INDEX `idx_czjilu_club` (`club_id`)', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 会员购买记录 +SET @exist := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'huiyuangoumai' AND COLUMN_NAME = 'club_id' +); +SET @sql := IF(@exist = 0, + 'ALTER TABLE `huiyuangoumai` ADD COLUMN `club_id` varchar(16) NOT NULL DEFAULT ''xq''', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 提现记录 +SET @exist := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tixianjilu' AND COLUMN_NAME = 'club_id' +); +SET @sql := IF(@exist = 0, + 'ALTER TABLE `tixianjilu` ADD COLUMN `club_id` varchar(16) NOT NULL DEFAULT ''xq''', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 罚单 +SET @exist := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fadan' AND COLUMN_NAME = 'club_id' +); +SET @sql := IF(@exist = 0, + 'ALTER TABLE `fadan` ADD COLUMN `club_id` varchar(16) NOT NULL DEFAULT ''xq''', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 平台收支汇总 +SET @exist := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'szjilu' AND COLUMN_NAME = 'club_id' +); +SET @sql := IF(@exist = 0, + 'ALTER TABLE `szjilu` ADD COLUMN `club_id` varchar(16) NOT NULL DEFAULT ''xq''', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 轮播:club_id + page_key +SET @exist := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lunbo' AND COLUMN_NAME = 'club_id' +); +SET @sql := IF(@exist = 0, + 'ALTER TABLE `lunbo` ADD COLUMN `club_id` varchar(16) NOT NULL DEFAULT ''xq''', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @exist := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lunbo' AND COLUMN_NAME = 'page_key' +); +SET @sql := IF(@exist = 0, + 'ALTER TABLE `lunbo` ADD COLUMN `page_key` varchar(32) NOT NULL DEFAULT ''order_pool''', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 弹窗页 +SET @exist := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'popup_page' AND COLUMN_NAME = 'club_id' +); +SET @sql := IF(@exist = 0, + 'ALTER TABLE `popup_page` ADD COLUMN `club_id` varchar(16) NOT NULL DEFAULT ''xq''', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 商品 catalog +SET @exist := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'shangpin' AND COLUMN_NAME = 'club_id' +); +SET @sql := IF(@exist = 0, + 'ALTER TABLE `shangpin` ADD COLUMN `club_id` varchar(16) NOT NULL DEFAULT ''xq''', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 每日收入/出款统计(按俱乐部) +SET @exist := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'daily_income_stat' AND COLUMN_NAME = 'club_id' +); +SET @sql := IF(@exist = 0, + 'ALTER TABLE `daily_income_stat` ADD COLUMN `club_id` varchar(16) NOT NULL DEFAULT ''xq'', ADD INDEX `idx_income_club_date` (`club_id`, `date`)', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @exist := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'daily_payout_stat' AND COLUMN_NAME = 'club_id' +); +SET @sql := IF(@exist = 0, + 'ALTER TABLE `daily_payout_stat` ADD COLUMN `club_id` varchar(16) NOT NULL DEFAULT ''xq'', ADD INDEX `idx_payout_club_date` (`club_id`, `date`)', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/jituan/urls.py b/jituan/urls.py new file mode 100644 index 0000000..58fdc22 --- /dev/null +++ b/jituan/urls.py @@ -0,0 +1,70 @@ +from django.urls import path + +from backend.view import ( + FaKuanChuLiView, + FaKuanChuangJianView, + FaKuanLieBiaoView, + FaKuanTongJiView, + GetGuanliListView, + GetOperationLogListView, + GetWithdrawSettingsView, + GetZuzhangListView, + KefuGetDashouListView, + KefuGetShangjiaListView, + PopupNoticeListAPIView, + PopupNoticeModifyAPIView, + UpdateWithdrawSettingsView, +) +from users.views import KefuPunishmentListView +from jituan.views_display import ClubGonggaoView, ClubLunboListView, ClubLunboManageView +from jituan.views import ( + ClubAuditLogListView, + ClubCaiwuView, + ClubChongzhiFinanceView, + ClubDashouRegisterView, + ClubHuiyuanBankuaiView, + ClubHuiyuanStatsView, + ClubKefuLoginView, + ClubListView, + ClubMeContextView, + ClubOrderFinanceView, + ClubOrderTypesView, + ClubSzxxView, + ClubUserSummaryView, + ClubWechatLoginView, +) + +urlpatterns = [ + path('auth/wechat-login', ClubWechatLoginView.as_view(), name='jituan_wechat_login'), + path('auth/kefu-login', ClubKefuLoginView.as_view(), name='jituan_kefu_login'), + path('auth/me-context', ClubMeContextView.as_view(), name='jituan_me_context'), + path('auth/dashou-register', ClubDashouRegisterView.as_view(), name='jituan_dashou_register'), + path('houtai/caiwu', ClubCaiwuView.as_view(), name='jituan_caiwu'), + path('houtai/szxx', ClubSzxxView.as_view(), name='jituan_szxx'), + path('houtai/audit-log', ClubAuditLogListView.as_view(), name='jituan_audit_log'), + path('houtai/hqczrz', GetOperationLogListView.as_view(), name='jituan_operation_log'), + path('houtai/hthqtxpz', GetWithdrawSettingsView.as_view(), name='jituan_withdraw_get'), + path('houtai/htxgtxsz', UpdateWithdrawSettingsView.as_view(), name='jituan_withdraw_update'), + path('houtai/cwddhqlx', ClubOrderTypesView.as_view(), name='jituan_cwddhqlx'), + path('houtai/cwhqjtddsj', ClubOrderFinanceView.as_view(), name='jituan_cwhqjtddsj'), + path('houtai/cwqtczhq', ClubChongzhiFinanceView.as_view(), name='jituan_cwqtczhq'), + path('houtai/cwhybkhq', ClubHuiyuanBankuaiView.as_view(), name='jituan_cwhybkhq'), + path('houtai/hybkjtsj', ClubHuiyuanStatsView.as_view(), name='jituan_hybkjtsj'), + path('houtai/user-summary', ClubUserSummaryView.as_view(), name='jituan_user_summary'), + # 用户列表(与 /houtai/* 同一视图,按 X-Club-Id 过滤;详情仍走 /houtai/*) + path('houtai/kefuhqdslb', KefuGetDashouListView.as_view(), name='jituan_dashou_list'), + path('houtai/hthqgslb', GetGuanliListView.as_view(), name='jituan_guanshi_list'), + path('houtai/hqsjgllb', KefuGetShangjiaListView.as_view(), name='jituan_shangjia_list'), + path('houtai/hthqzzlb', GetZuzhangListView.as_view(), name='jituan_zuzhang_list'), + path('houtai/lunbo-list', ClubLunboListView.as_view(), name='jituan_lunbo_list'), + path('houtai/lunbo-manage', ClubLunboManageView.as_view(), name='jituan_lunbo_manage'), + path('houtai/gonggao', ClubGonggaoView.as_view(), name='jituan_gonggao'), + path('houtai/hthqtcxx', PopupNoticeListAPIView.as_view(), name='jituan_popup_list'), + path('houtai/htxgtcxx', PopupNoticeModifyAPIView.as_view(), name='jituan_popup_modify'), + path('houtai/htglyhqcfsltj', FaKuanTongJiView.as_view(), name='jituan_penalty_stats'), + path('houtai/hthqfklb', FaKuanLieBiaoView.as_view(), name='jituan_penalty_list'), + path('houtai/glyclfk', FaKuanChuLiView.as_view(), name='jituan_penalty_action'), + path('houtai/htfksc', FaKuanChuangJianView.as_view(), name='jituan_penalty_create'), + path('houtai/kefu-cfgl', KefuPunishmentListView.as_view(), name='jituan_kefu_cfgl'), + path('club/list', ClubListView.as_view(), name='jituan_club_list'), +] diff --git a/jituan/views.py b/jituan/views.py new file mode 100644 index 0000000..c48e192 --- /dev/null +++ b/jituan/views.py @@ -0,0 +1,477 @@ +"""集团平台 API 视图。""" +import logging + +from django.utils import timezone +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.throttling import AnonRateThrottle +from rest_framework_simplejwt.tokens import RefreshToken + +from jituan.models import Club +from jituan.services.admin_context import build_admin_club_context +from jituan.services.wechat_login import login_or_register_by_club +from jituan.services.caiwu_stats import build_caiwu_payload +from jituan.services.szxx_stats import build_szxx_payload +from jituan.services.admin_audit import list_admin_audit_logs +from jituan.services.user_stats import build_user_summary +from jituan.services.finance_detail_stats import ( + build_chongzhi_finance_payload, + build_huiyuan_bankuai_payload, + build_huiyuan_stats_payload, + build_order_finance_payload, + build_order_types_payload, +) +from backend.utils import verify_kefu_permission +from users.business_models import User + +logger = logging.getLogger(__name__) + + +def _client_ip(request): + xff = request.META.get('HTTP_X_FORWARDED_FOR') + if xff: + return xff.split(',')[0].strip() + return request.META.get('REMOTE_ADDR', '') + + +class ClubWechatLoginView(APIView): + """ + 多俱乐部微信小程序登录(新接口,旧 /yonghu/wechatlogin 不变)。 + POST /jituan/auth/wechat-login + body: { code, club_id?, app_id? } + """ + throttle_classes = [AnonRateThrottle] + permission_classes = [AllowAny] + + def post(self, request): + code = (request.data.get('code') or '').strip() + club_id = (request.data.get('club_id') or '').strip() or None + app_id = (request.data.get('app_id') or '').strip() or None + + if not code: + return Response({'code': 1, 'msg': '微信授权码不能为空', 'data': None}, + status=status.HTTP_400_BAD_REQUEST) + + try: + data, err = login_or_register_by_club( + code, club_id=club_id, app_id=app_id, client_ip=_client_ip(request), + ) + if err: + return Response({'code': 2, 'msg': f'微信登录失败: {err}', 'data': None}, + status=status.HTTP_400_BAD_REQUEST) + return Response({'code': 0, 'msg': '登录成功', 'data': data}) + except Exception as e: + logger.exception('ClubWechatLoginView 异常') + return Response({'code': 99, 'msg': '系统繁忙,请稍后重试', 'data': None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class ClubKefuLoginView(APIView): + """ + 多俱乐部客服/集团后台登录(新接口,旧 /yonghu/kefujinru 不变)。 + POST /jituan/auth/kefu-login + body: { phone, password, erjimima } + """ + throttle_classes = [AnonRateThrottle] + permission_classes = [AllowAny] + + def post(self, request): + phone = (request.data.get('phone') or '').strip() + password = request.data.get('password') or '' + erjimima = request.data.get('erjimima') or '' + + if not phone or not password: + return Response({'code': 1, 'msg': '请填写手机号和密码', 'data': None}, + status=status.HTTP_400_BAD_REQUEST) + + user_mains = list( + User.query.filter(Phone=phone, KefuProfile__isnull=False).select_related('KefuProfile') + ) + if not user_mains: + admin_candidates = User.query.filter(Phone=phone) + for admin_user in admin_candidates: + if admin_user.IsSuperuser or admin_user.UserType == 'admin': + user_mains = [admin_user] + break + + if not user_mains: + return Response({'code': 1, 'msg': '用户不存在', 'data': None}, + status=status.HTTP_400_BAD_REQUEST) + + target_user = None + target_kefu = None + password_matched = False + + for user in user_mains: + if not user.CheckPassword(password): + continue + password_matched = True + is_admin = user.IsSuperuser or user.UserType == 'admin' + if is_admin: + target_user = user + target_kefu = None + break + kefu = user.KefuProfile + stored_erji = kefu.erjimima or '' + if stored_erji.startswith(('$2b$', '$2a$')) and len(stored_erji) == 60: + import bcrypt as _bcrypt + if not _bcrypt.checkpw(erjimima.encode('utf-8'), stored_erji.encode('utf-8')): + continue + elif stored_erji != erjimima: + continue + if kefu.zhuangtai != 1: + if target_user is None: + target_user = user + target_kefu = kefu + continue + target_user = user + target_kefu = kefu + break + else: + if target_user and target_kefu: + return Response({'code': 4, 'msg': '账号已被封禁', 'data': None}, + status=status.HTTP_403_FORBIDDEN) + if password_matched: + return Response({'code': 1, 'msg': '二级密码错误', 'data': None}, + status=status.HTTP_400_BAD_REQUEST) + return Response({'code': 1, 'msg': '密码错误', 'data': None}, + status=status.HTTP_400_BAD_REQUEST) + + target_user.IP = _client_ip(request) + target_user.UserLastLoginDate = timezone.now() + target_user.save(update_fields=['IP', 'UserLastLoginDate']) + + refresh = RefreshToken.for_user(target_user) + token = str(refresh.access_token) + nicheng = target_kefu.nicheng if target_kefu else (target_user.UserName or target_user.Phone) + club_context = build_admin_club_context(target_user) + + return Response({ + 'code': 0, + 'msg': '登录成功', + 'data': { + 'token': token, + 'nicheng': nicheng, + 'yonghuid': target_user.UserUID, + 'club_context': club_context, + }, + }) + + +class ClubListView(APIView): + """GET /jituan/club/list — 启用中的俱乐部列表(登录前后均可读)。""" + permission_classes = [AllowAny] + + def get(self, request): + clubs = Club.query.filter(status=1).order_by('sort_order', 'club_id') + data = [ + { + 'club_id': c.club_id, + 'name': c.name, + 'wx_appid': c.wx_appid, + 'h5_domain': c.h5_domain, + } + for c in clubs + ] + return Response({'code': 0, 'msg': 'ok', 'data': data}) + + +class ClubMeContextView(APIView): + """GET /jituan/auth/me-context — 已登录客服刷新俱乐部上下文。""" + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + if user.UserType not in ('kefu', 'admin') and not user.IsSuperuser: + return Response({'code': 403, 'msg': '非后台账号', 'data': None}, status=403) + return Response({ + 'code': 0, + 'msg': 'ok', + 'data': build_admin_club_context(user), + }) + + +class ClubCaiwuView(APIView): + """ + 俱乐部维度财务(新接口)。 + 旧 POST /houtai/caiwu 完全不变;kefu 切换俱乐部后应调本接口。 + Header: X-Club-Id, 集团汇总 X-Club-Scope: all + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + username = request.data.get('phone', None) + kefu_obj, permissions = verify_kefu_permission(request, username) + if kefu_obj is None: + return permissions + if 'caiwu' not in permissions: + return Response({'code': 403, 'msg': '无财务查看权限'}, status=403) + try: + data = build_caiwu_payload(request) + return Response({'code': 0, 'msg': 'ok', 'data': data}) + except Exception: + logger.exception('ClubCaiwuView 异常') + return Response({'code': 99, 'msg': '统计失败', 'data': None}, status=500) + + +class ClubSzxxView(APIView): + """ + 俱乐部维度每日收支详细统计(新接口)。 + 旧 POST /houtai/szxx 不变;参数与旧接口一致。 + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + username = request.data.get('phone', None) + kefu_obj, permissions = verify_kefu_permission(request, username) + if kefu_obj is None: + return permissions + if 'caiwu' not in permissions: + return Response({'code': 403, 'msg': '无财务查看权限'}, status=403) + + granularity = request.data.get('granularity', 'day') + year = request.data.get('year') + month = request.data.get('month') + summary_date = request.data.get('summary_date') + + try: + if year is not None: + year = int(year) + if month is not None: + month = int(month) + except (ValueError, TypeError): + return Response({'code': 1, 'msg': '年份或月份格式错误'}, status=400) + + try: + data, err = build_szxx_payload(request, granularity, year, month, summary_date) + if err: + return Response({'code': 1, 'msg': err}, status=400) + return Response({'code': 0, 'msg': 'ok', 'data': data}) + except Exception: + logger.exception('ClubSzxxView 异常') + return Response({'code': 99, 'msg': '统计失败'}, status=500) + + +class ClubAuditLogListView(APIView): + """ + 后台审计日志(admin_audit_log)。 + POST /jituan/houtai/audit-log + 权限:000001 或 czrz666(与操作日志一致) + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + username = (request.data.get('username') or request.data.get('phone') or '').strip() + if not username: + return Response({'code': 401, 'msg': '缺少username'}) + + kefu, permissions = verify_kefu_permission(request, username) + if kefu is None: + return permissions + if '000001' not in permissions and 'czrz666' not in permissions: + return Response({'code': 403, 'msg': '无权限查看审计日志'}) + + try: + page = int(request.data.get('page', 1)) + except (ValueError, TypeError): + page = 1 + try: + page_size = int(request.data.get('page_size', 20)) + except (ValueError, TypeError): + page_size = 20 + + data = list_admin_audit_logs( + request, + page=page, + page_size=page_size, + operator_yonghuid=request.data.get('operator_yonghuid'), + target_yonghuid=request.data.get('target_yonghuid'), + action=request.data.get('action'), + keyword=request.data.get('keyword'), + ) + return Response({'code': 0, 'msg': 'ok', 'data': data}) + + +def _parse_year_month(data): + year = data.get('year') + month = data.get('month') + try: + if year is not None: + year = int(year) + if month is not None: + month = int(month) + except (ValueError, TypeError): + return None, None, Response({'code': 1, 'msg': '年份或月份格式错误'}, status=400) + return year, month, None + + +class ClubOrderTypesView(APIView): + """POST /jituan/houtai/cwddhqlx — 订单财务商品类型筛选。""" + permission_classes = [IsAuthenticated] + + def post(self, request): + username = request.data.get('phone', None) + kefu_obj, permissions = verify_kefu_permission(request, username) + if kefu_obj is None: + return permissions + if 'dingdancaiwu' not in permissions: + return Response({'code': 403, 'msg': '无订单财务查看权限'}, status=403) + return Response({'code': 0, 'msg': 'ok', 'data': build_order_types_payload()}) + + +class ClubOrderFinanceView(APIView): + """POST /jituan/houtai/cwhqjtddsj — 订单财务详细统计(按俱乐部)。""" + permission_classes = [IsAuthenticated] + + def post(self, request): + username = request.data.get('phone', None) + kefu_obj, permissions = verify_kefu_permission(request, username) + if kefu_obj is None: + return permissions + if 'dingdancaiwu' not in permissions: + return Response({'code': 403, 'msg': '无订单财务查看权限'}, status=403) + + year, month, err_resp = _parse_year_month(request.data) + if err_resp: + return err_resp + try: + data, err = build_order_finance_payload( + request, + request.data.get('granularity', 'day'), + year, + month, + request.data.get('type_id', 0), + request.data.get('order_source', 0), + request.data.get('summary_date'), + ) + if err: + return Response({'code': 1, 'msg': err}, status=400) + return Response({'code': 0, 'msg': 'ok', 'data': data}) + except Exception: + logger.exception('ClubOrderFinanceView 异常') + return Response({'code': 99, 'msg': '统计失败'}, status=500) + + +class ClubChongzhiFinanceView(APIView): + """POST /jituan/houtai/cwqtczhq — 其他充值/罚款财务统计(按俱乐部)。""" + permission_classes = [IsAuthenticated] + + def post(self, request): + username = request.data.get('phone', None) + kefu_obj, permissions = verify_kefu_permission(request, username) + if kefu_obj is None: + return permissions + if 'qtczcaiwu' not in permissions: + return Response({'code': 403, 'msg': '无其他充值财务查看权限'}, status=403) + + year, month, err_resp = _parse_year_month(request.data) + if err_resp: + return err_resp + try: + data, err = build_chongzhi_finance_payload( + request, + request.data.get('granularity', 'day'), + year, + month, + request.data.get('types', [2, 3, 4, 5, 6]), + request.data.get('summary_date'), + ) + if err: + return Response({'code': 1, 'msg': err}, status=400) + return Response({'code': 0, 'msg': 'ok', 'data': data}) + except Exception: + logger.exception('ClubChongzhiFinanceView 异常') + return Response({'code': 99, 'msg': '统计失败'}, status=500) + + +class ClubHuiyuanBankuaiView(APIView): + """POST /jituan/houtai/cwhybkhq — 板块及会员列表(附带俱乐部售价)。""" + permission_classes = [IsAuthenticated] + + def post(self, request): + username = request.data.get('phone', None) + kefu_obj, permissions = verify_kefu_permission(request, username) + if kefu_obj is None: + return permissions + if 'huiyuancaiwu' not in permissions: + return Response({'code': 403, 'msg': '无会员财务查看权限'}, status=403) + payload = build_huiyuan_bankuai_payload(request) + return Response({ + 'code': 0, + 'msg': 'ok', + 'data': payload['bankuai_list'], + 'club_id': payload['club_id'], + 'scope': payload['scope'], + }) + + +class ClubHuiyuanStatsView(APIView): + """POST /jituan/houtai/hybkjtsj — 会员财务详细统计(按俱乐部)。""" + permission_classes = [IsAuthenticated] + + def post(self, request): + username = request.data.get('phone', None) + kefu_obj, permissions = verify_kefu_permission(request, username) + if kefu_obj is None: + return permissions + if 'huiyuancaiwu' not in permissions: + return Response({'code': 403, 'msg': '无会员财务查看权限'}, status=403) + + year, month, err_resp = _parse_year_month(request.data) + if err_resp: + return err_resp + try: + data, err = build_huiyuan_stats_payload( + request, + request.data.get('huiyuan_id'), + request.data.get('granularity', 'day'), + year, + month, + request.data.get('include_guanshi', False), + request.data.get('include_zuzhang', False), + request.data.get('summary_date'), + ) + if err: + return Response({'code': 1, 'msg': err}, status=400) + return Response({'code': 0, 'msg': 'ok', 'data': data}) + except Exception: + logger.exception('ClubHuiyuanStatsView 异常') + return Response({'code': 99, 'msg': '统计失败'}, status=500) + + +class ClubUserSummaryView(APIView): + """ + 用户管理汇总(打手/管事/商家/组长数量)。 + 集团视图 X-Club-Scope: all 时额外返回 by_club 各公司拆分。 + POST /jituan/houtai/user-summary + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + username = (request.data.get('phone') or request.data.get('username') or '').strip() + if not username: + return Response({'code': 401, 'msg': '缺少账号'}, status=401) + kefu_obj, permissions = verify_kefu_permission(request, username) + if kefu_obj is None: + return permissions + try: + data = build_user_summary(request) + return Response({'code': 0, 'msg': 'ok', 'data': data}) + except Exception: + logger.exception('ClubUserSummaryView 异常') + return Response({'code': 99, 'msg': '统计失败'}, status=500) + + +class ClubDashouRegisterView(APIView): + """ + 打手邀请码注册(带俱乐部校验)。 + POST /jituan/auth/dashou-register body: { inviteCode } + 与 /yonghu/dashouzhuce 逻辑一致,推荐新小程序使用。 + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + from users.views import DashouZhuceView + return DashouZhuceView().post(request) diff --git a/jituan/views_display.py b/jituan/views_display.py new file mode 100644 index 0000000..66edb28 --- /dev/null +++ b/jituan/views_display.py @@ -0,0 +1,157 @@ +"""小程序展示配置:轮播图 / 公告(按俱乐部)。""" +import os +import random +import string + +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from backend.utils import verify_kefu_permission +from config.models import Gonggao, Lunbo +from jituan.services.club_context import resolve_club_id_from_request +from jituan.services.display_config import ( + forbid_display_write_in_all_scope, + get_gonggao_content, + get_lunbo_urls, + list_response_meta, + LUNBO_PAGE_DASHOU_CENTER, + LUNBO_PAGE_OPTIONS, + normalize_page_key, +) +from products.views import delete_from_oss, upload_to_oss, validate_image + +POPUP_PERM = '8080a' + + +class ClubLunboListView(APIView): + """GET 轮播列表 POST /jituan/houtai/lunbo-list""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + phone = (request.data.get('phone') or request.data.get('username') or '').strip() + if not phone: + return Response({'code': 401, 'msg': '缺少客服账号'}) + kefu, permissions = verify_kefu_permission(request, phone) + if kefu is None: + return permissions + if POPUP_PERM not in permissions: + return Response({'code': 403, 'msg': '无权限管理轮播图'}) + + page_key = normalize_page_key(request.data.get('page_key'), image_type=1) + image_type = 2 if page_key == LUNBO_PAGE_DASHOU_CENTER else 1 + urls = get_lunbo_urls(request, page_key=page_key, image_type=image_type) + return Response({ + 'code': 0, + 'data': { + 'page_key': page_key, + 'list': urls, + 'page_options': [{'key': k, 'label': v} for k, v in LUNBO_PAGE_OPTIONS], + **list_response_meta(request), + }, + }) + + +class ClubLunboManageView(APIView): + """轮播增删 POST /jituan/houtai/lunbo-manage""" + permission_classes = [IsAuthenticated] + parser_classes = [MultiPartParser, FormParser, JSONParser] + + def post(self, request): + phone = (request.data.get('phone') or request.data.get('username') or '').strip() + if not phone: + return Response({'code': 401, 'msg': '缺少客服账号'}) + kefu, permissions = verify_kefu_permission(request, phone) + if kefu is None: + return permissions + if POPUP_PERM not in permissions: + return Response({'code': 403, 'msg': '无权限管理轮播图'}) + + deny = forbid_display_write_in_all_scope(request) + if deny: + return deny + + action = (request.data.get('action') or '').strip() + club_id = resolve_club_id_from_request(request) + page_key = normalize_page_key(request.data.get('page_key'), image_type=1) + image_type = 2 if page_key == LUNBO_PAGE_DASHOU_CENTER else 1 + + if action == 'delete': + tupian_url = (request.data.get('tupian_url') or '').strip() + if not tupian_url: + return Response({'code': 400, 'msg': '图片URL不能为空'}) + row = Lunbo.query.filter( + club_id=club_id, ImageURL=tupian_url, ImageType=image_type, page_key=page_key, + ).first() + if not row: + return Response({'code': 404, 'msg': '轮播图不存在'}) + delete_from_oss(tupian_url) + row.delete() + return Response({'code': 0, 'msg': '删除成功'}) + + if action == 'add': + file_obj = request.FILES.get('file') + if not file_obj: + return Response({'code': 400, 'msg': '请上传图片'}) + ok, msg = validate_image(file_obj) + if not ok: + return Response({'code': 400, 'msg': msg}) + ext = os.path.splitext(file_obj.name)[1].lower() or '.jpg' + random_str = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6)) + oss_file_path = f"a_long/lunbo/{random_str}{ext}" + if not upload_to_oss(file_obj, oss_file_path): + return Response({'code': 500, 'msg': '图片上传失败'}) + Lunbo.query.create( + club_id=club_id, + page_key=page_key, + ImageURL=oss_file_path, + ImageType=image_type, + ) + return Response({'code': 0, 'msg': '添加成功', 'data': {'tupian_url': oss_file_path}}) + + return Response({'code': 400, 'msg': 'action 必须为 add 或 delete'}) + + +class ClubGonggaoView(APIView): + """公告读写 POST /jituan/houtai/gonggao""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + phone = (request.data.get('phone') or request.data.get('username') or '').strip() + if not phone: + return Response({'code': 401, 'msg': '缺少客服账号'}) + kefu, permissions = verify_kefu_permission(request, phone) + if kefu is None: + return permissions + if POPUP_PERM not in permissions: + return Response({'code': 403, 'msg': '无权限管理公告'}) + + notice_type = int(request.data.get('notice_type', 1) or 1) + club_id = resolve_club_id_from_request(request) + + content = request.data.get('content') + if content is None: + return Response({ + 'code': 0, + 'data': { + 'content': get_gonggao_content(request, notice_type=notice_type), + 'notice_type': notice_type, + **list_response_meta(request), + }, + }) + + deny = forbid_display_write_in_all_scope(request) + if deny: + return deny + + row, _ = Gonggao.query.get_or_create( + club_id=club_id, + NoticeType=notice_type, + defaults={'Content': content}, + ) + row.Content = content + row.save(update_fields=['Content', 'UpdateTime']) + return Response({'code': 0, 'msg': '保存成功'}) diff --git a/orders/migrations/0004_order_clubid.py b/orders/migrations/0004_order_clubid.py new file mode 100644 index 0000000..f9692cd --- /dev/null +++ b/orders/migrations/0004_order_clubid.py @@ -0,0 +1,24 @@ +# Generated manually for jituan multi-club + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0003_delete_dingdan_delete_dingdanpingtai_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='ClubID', + field=models.CharField( + db_column='club_id', + db_index=True, + default='xq', + max_length=16, + verbose_name='所属俱乐部', + ), + ), + ] diff --git a/orders/migrations/0005_penalty_clubid.py b/orders/migrations/0005_penalty_clubid.py new file mode 100644 index 0000000..e807002 --- /dev/null +++ b/orders/migrations/0005_penalty_clubid.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0004_order_clubid'), + ] + + operations = [ + migrations.AddField( + model_name='penalty', + name='ClubID', + field=models.CharField( + db_column='club_id', + db_index=True, + default='xq', + max_length=16, + verbose_name='所属俱乐部', + ), + ), + ] diff --git a/orders/migrations/0006_penaltybonus_clubid.py b/orders/migrations/0006_penaltybonus_clubid.py new file mode 100644 index 0000000..7f379f1 --- /dev/null +++ b/orders/migrations/0006_penaltybonus_clubid.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0005_penalty_clubid'), + ] + + operations = [ + migrations.AddField( + model_name='penaltybonus', + name='ClubID', + field=models.CharField( + db_column='club_id', + db_index=True, + default='xq', + max_length=16, + verbose_name='所属俱乐部', + ), + ), + ] diff --git a/orders/migrations/0007_penaltyrecord_clubid.py b/orders/migrations/0007_penaltyrecord_clubid.py new file mode 100644 index 0000000..b62f27a --- /dev/null +++ b/orders/migrations/0007_penaltyrecord_clubid.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0006_penaltybonus_clubid'), + ] + + operations = [ + migrations.AddField( + model_name='penaltyrecord', + name='ClubID', + field=models.CharField( + db_column='club_id', + db_index=True, + default='xq', + max_length=16, + verbose_name='所属俱乐部', + ), + ), + ] diff --git a/orders/models.py b/orders/models.py index d4f5444..507851d 100644 --- a/orders/models.py +++ b/orders/models.py @@ -51,6 +51,10 @@ class Order(QModel): null=True, blank=True, db_column='fadan_pingtai', verbose_name='发单平台', ) + ClubID = models.CharField( + max_length=16, default='xq', db_index=True, + db_column='club_id', verbose_name='所属俱乐部', + ) # ---- 金额 ---- Amount = models.DecimalField( max_digits=10, decimal_places=2, null=True, blank=True, @@ -502,6 +506,10 @@ class Penalty(QModel): max_digits=12, decimal_places=2, default=0.00, db_column='shenqingren_fenhong_jine', verbose_name='申请人分红金额', ) + ClubID = models.CharField( + max_length=16, default='xq', db_index=True, + db_column='club_id', verbose_name='所属俱乐部', + ) class Meta: db_table = 'fadan' @@ -599,6 +607,10 @@ class PenaltyBonus(QModel): auto_now=True, verbose_name='更新时间', ) + ClubID = models.CharField( + max_length=16, default='xq', db_index=True, + db_column='club_id', verbose_name='所属俱乐部', + ) class Meta: db_table = 'fadan_fenhong' @@ -762,6 +774,10 @@ class PenaltyRecord(QModel): max_length=32, null=True, db_index=True, db_column='dingdan_id', verbose_name='订单ID', ) + ClubID = models.CharField( + max_length=16, default='xq', db_index=True, + db_column='club_id', verbose_name='所属俱乐部', + ) CreateTime = models.DateTimeField( auto_now_add=True, verbose_name='创建时间', diff --git a/orders/utils.py b/orders/utils.py index 696a52f..222709e 100644 --- a/orders/utils.py +++ b/orders/utils.py @@ -7,7 +7,9 @@ from django.db.models import F from config.models import DailyIncomeStat, DailyPayoutStat from orders.models import Order, PlatformOrderExt, MerchantOrderExt, CommissionRate +from jituan.services.club_config import get_commission_rate from products.models import Gsfenhong +from jituan.services.club_penalty import resolve_gsfenhong_club_id from users.models import UserDashou, UserGuanshi from backend.utils import update_guanshi_daily_by_action @@ -31,71 +33,66 @@ def get_order_user_id(order): return None -def update_daily_income(amount): +def update_daily_income(amount, club_id=None): """ 原子更新当日收入统计(金额累加,笔数加1)。 用于微信支付成功回调。 - - 参数: - amount: Decimal 本次收入金额 """ + from jituan.constants import CLUB_ID_DEFAULT + cid = club_id or CLUB_ID_DEFAULT today = date.today() year, month, day = today.year, today.month, today.day with transaction.atomic(): stat, created = DailyIncomeStat.objects.select_for_update().get_or_create( date=today, + club_id=cid, defaults={ 'year': year, 'month': month, 'day': day, 'total_amount': amount, - 'total_count': 1 - } + 'total_count': 1, + }, ) if not created: - # 原子累加 stat.total_amount = F('total_amount') + amount stat.total_count = F('total_count') + 1 stat.save(update_fields=['total_amount', 'total_count']) logger.info( - f"每日收入更新: {today}, +{amount}元, 总金额={stat.total_amount if created else stat.total_amount + amount}, 总笔数={stat.total_count if created else stat.total_count + 1}") + f"每日收入更新[{cid}]: {today}, +{amount}元" + ) - - - - -def update_daily_payout(amount): +def update_daily_payout(amount, club_id=None): """ 原子更新当日出款统计(金额累加,笔数加1)。 用于提现成功、结算打款等场景。 - - 参数: - amount: Decimal 本次出款金额 """ + from jituan.constants import CLUB_ID_DEFAULT + cid = club_id or CLUB_ID_DEFAULT today = date.today() year, month, day = today.year, today.month, today.day with transaction.atomic(): stat, created = DailyPayoutStat.objects.select_for_update().get_or_create( date=today, + club_id=cid, defaults={ 'year': year, 'month': month, 'day': day, 'total_amount': amount, - 'total_count': 1 - } + 'total_count': 1, + }, ) if not created: - # 原子累加 stat.total_amount = F('total_amount') + amount stat.total_count = F('total_count') + 1 stat.save(update_fields=['total_amount', 'total_count']) - logger.info(f"每日出款更新: {today}, +{amount}元, 总金额={stat.total_amount if created else stat.total_amount + amount}, 总笔数={stat.total_count if created else stat.total_count + 1}") + logger.info(f"每日出款更新[{cid}]: {today}, +{amount}元") @@ -104,24 +101,19 @@ def update_daily_payout(amount): -def calc_shangjia_order_fencheng(jine): +def calc_shangjia_order_fencheng(jine, club_id=None): """ 商家发单时计算打手/管事分成。 打手优先:打手+管事 > 订单金额时,管事分成为 0。 """ + from jituan.constants import CLUB_ID_DEFAULT jine = Decimal(str(jine)) - try: - lilu_obj = CommissionRate.objects.get(Platform='3') - rate_dashou = Decimal(str(lilu_obj.Rate)) if lilu_obj.Rate and lilu_obj.Rate > 0 else Decimal('1') - except CommissionRate.DoesNotExist: + cid = club_id or CLUB_ID_DEFAULT + rate_dashou = get_commission_rate(cid, '3') + if not rate_dashou or rate_dashou <= 0: rate_dashou = Decimal('1') - lilu_guanshi_obj = CommissionRate.objects.filter(Platform='13').first() - rate_guanshi = ( - Decimal(str(lilu_guanshi_obj.Rate)) - if lilu_guanshi_obj and lilu_guanshi_obj.Rate is not None - else Decimal('0') - ) + rate_guanshi = get_commission_rate(cid, '13') dashou_fencheng = (jine * rate_dashou).quantize(Decimal('0.01')) guanshi_raw = (jine * rate_guanshi).quantize(Decimal('0.01')) @@ -182,6 +174,7 @@ def settle_shangjia_order_guanshi_fenhong(order, dashou_id): avatar=user_main.Avatar if user_main else None, nicheng=dashou.nicheng or '未知打手', fenhong_leixing=3, + club_id=resolve_gsfenhong_club_id(dingdan_id=order.OrderID, dashouid=dashou_id, order=order), ) logger.info( f"商家订单管事分红成功: 订单{order.OrderID}, 管事{guanshi_id}, " diff --git a/orders/views.py b/orders/views.py index eee08d8..02a2558 100644 --- a/orders/views.py +++ b/orders/views.py @@ -56,17 +56,18 @@ from orders.notice_tasks import dingdan_guangbo from .models import ( Order, MerchantOrderExt, PlatformOrderExt, PlayerDeliveryImage, - PenaltyRecord, PenaltyEvidenceImage, Penalty, PenaltyBonus -) -from orders.models import ( - CommissionRate, PlayerRating, RefundRecord + PenaltyRecord, PenaltyEvidenceImage, Penalty, PenaltyBonus, + CommissionRate, PlayerRating, RefundRecord, ) +from jituan.services.club_config import get_commission_rate, get_commission_rate_object +from jituan.services.club_context import resolve_club_id_from_request +from jituan.services.club_penalty import resolve_penalty_club_id from products.models import Shangpin, ShangpinLeixing, Huiyuangoumai from users.models import UserDashou, UserShangjia, UserBoss from users.business_models import User from rank.models import DashouBiaoxian, Chenghao, DingdanBiaoqian, YonghuChenghao from config.models import ( - Szjilu, ShangjiaLianjie + ShangjiaLianjie ) @@ -133,8 +134,11 @@ class WechatPayNotifyView(APIView): out_trade_no = root.find('out_trade_no').text if root.find('out_trade_no') is not None else '' transaction_id = root.find('transaction_id').text if root.find('transaction_id') is not None else '' + from jituan.services.wechat_pay import club_id_from_order, verify_wechat_v2_signature + notify_club_id = club_id_from_order(out_trade_no) + # 3. 签名验证 - if not self._verify_signature(xml_data): + if not verify_wechat_v2_signature(xml_data, notify_club_id): logger.warning("签名验证失败") return self._gen_response_xml('FAIL', '签名验证失败') @@ -303,10 +307,10 @@ class WechatPayNotifyView(APIView): # 更新收支记录(总累计) - self._update_szjilu(dingdan.Amount) + self._update_szjilu(dingdan.Amount, getattr(dingdan, 'ClubID', None)) # 更新每日收入统计 - update_daily_income(dingdan.Amount) + update_daily_income(dingdan.Amount, getattr(dingdan, 'ClubID', None)) # 构建通知数据(你的 _get_game_type_name 方法保留在视图里直接用) order_info = { @@ -346,28 +350,12 @@ class WechatPayNotifyView(APIView): logger.error(f"处理支付失败异常: {e}") logger.error(traceback.format_exc()) - def _update_szjilu(self, jine): - """更新收支记录表(ID=1的记录)""" + def _update_szjilu(self, jine, club_id=None): + """更新收支记录表(按俱乐部)""" + from jituan.services.szjilu_accounting import apply_szjilu_income try: - jine_decimal = Decimal(str(jine)) - with transaction.atomic(): - szjilu, created = Szjilu.objects.select_for_update().get_or_create( - id=1, - defaults={ - 'TotalIncome': Decimal('0.00'), - 'TotalFlow': Decimal('0.00'), - 'TotalExpense': Decimal('0.00'), - 'DailyExpense': Decimal('0.00'), - 'DailyFlow': Decimal('0.00') - } - ) - if created: - logger.info("创建ID=1的收支记录") - szjilu.TotalIncome += jine_decimal - szjilu.TotalFlow += jine_decimal - szjilu.DailyFlow += jine_decimal - szjilu.save() - logger.info(f"收支记录更新成功,增加金额: {jine}") + apply_szjilu_income(jine, club_id) + logger.info(f"收支记录更新成功,增加金额: {jine}, club={club_id}") except Exception as e: logger.error(f"更新收支记录失败: {e}") @@ -580,10 +568,10 @@ class PaymentVerifyView(APIView): # 更新收支记录 - self._update_szjilu(dingdan.Amount) + self._update_szjilu(dingdan.Amount, getattr(dingdan, 'ClubID', None)) # 更新每日收入统计 - update_daily_income(dingdan.Amount) + update_daily_income(dingdan.Amount, getattr(dingdan, 'ClubID', None)) def _query_wechat_payment(self, out_trade_no): """ @@ -620,28 +608,12 @@ class PaymentVerifyView(APIView): err_msg = result.get('return_msg', '未知错误') raise Exception(f"微信订单查询失败: {err_msg}") - def _update_szjilu(self, jine): - """更新收支记录表(ID=1的记录)""" + def _update_szjilu(self, jine, club_id=None): + """更新收支记录表(按俱乐部)""" + from jituan.services.szjilu_accounting import apply_szjilu_income try: - jine_decimal = Decimal(str(jine)) - with transaction.atomic(): - szjilu, created = Szjilu.objects.select_for_update().get_or_create( - id=1, - defaults={ - 'TotalIncome': Decimal('0.00'), - 'TotalFlow': Decimal('0.00'), - 'TotalExpense': Decimal('0.00'), - 'DailyExpense': Decimal('0.00'), - 'DailyFlow': Decimal('0.00') - } - ) - if created: - logger.info("创建ID=1的收支记录") - szjilu.TotalIncome += jine_decimal - szjilu.TotalFlow += jine_decimal - szjilu.DailyFlow += jine_decimal - szjilu.save() - logger.info(f"收支记录更新成功,增加金额: {jine}") + apply_szjilu_income(jine, club_id) + logger.info(f"收支记录更新成功,增加金额: {jine}, club={club_id}") except Exception as e: logger.error(f"更新收支记录失败: {e}") # 不抛出异常,因为收支记录不是核心流程,但记录错误供排查 @@ -738,8 +710,8 @@ class CreateOrderView(APIView): dingdanid = f"PT{timestamp:011d}{timestamp_ns:06d}{random_num:02d}" # 6. 查询利率并计算分成 - # 查询打手利率(字符'1') - dashou_lilu = CommissionRate.query.filter(Platform='1').first() + club_id = resolve_club_id_from_request(request) + dashou_lilu = get_commission_rate_object(club_id, '1') # 🔥 新增:判断是否使用商品配置的额外分成 @@ -754,11 +726,9 @@ class CreateOrderView(APIView): # 查询管事利率(字符'2') #guanshi_lilu = CommissionRate.query.filter(Platform='2').first() - if not dashou_lilu: #or not guanshi_lilu: - return Response({'code': 9, 'msg': '系统利率配置不完整', 'data': None}) - - # 计算打手分成 - #dashou_fencheng = Decimal(str(shangpin_jiage)) * Decimal(str(dashou_lilu.Rate)) + if not (shangpin.kaioi_ewai_dashou_fencheng and shangpin.ewai_dashou_fencheng is not None): + if get_commission_rate(club_id, '1') <= 0 and not CommissionRate.query.filter(Platform='1').exists(): + return Response({'code': 9, 'msg': '系统利率配置不完整', 'data': None}) # 开始数据库事务 @@ -788,7 +758,8 @@ class CreateOrderView(APIView): 'Nickname': nicheng, 'ImageURL':shangpin_tupian, 'User1ID':f"Boss{request.user.UserUID}", - 'ProductTypeID':shangpin.leixing_id + 'ProductTypeID':shangpin.leixing_id, + 'ClubID': getattr(request, 'club_id', None) or 'xq', } # 创建订单记录 @@ -1943,7 +1914,8 @@ class ShangjiaPaifaView(APIView): return Response({'code': 400, 'msg': '指定用户不是打手'}) # ----- 10. 利率(打手+管事分成,打手优先) ----- - dashou_fencheng, guanshi_fencheng = calc_shangjia_order_fencheng(jiage) + club_id = resolve_club_id_from_request(request) + dashou_fencheng, guanshi_fencheng = calc_shangjia_order_fencheng(jiage, club_id=club_id) # ----- 11. 生成订单ID ----- def generate_dingdan_id(): @@ -1973,7 +1945,8 @@ class ShangjiaPaifaView(APIView): Remark=dingdan_beizhu[:1000], Nickname=laoban_name[:50], ImageURL=request.user.Avatar, - User1ID=f"Sj{request.user.UserUID}" + User1ID=f"Sj{request.user.UserUID}", + ClubID=club_id, ) logger.info(f"订单主表创建: {dingdan.OrderID}") @@ -5579,19 +5552,11 @@ class AdTongYiTuiKuanPingTai(APIView): except (User.DoesNotExist, AttributeError): logger.warning(f"平台退款:老板{laoban_id}不存在或扩展信息缺失,跳过") - # 16. 更新收支记录表(id=1) + # 16. 更新收支记录表(按俱乐部) try: - - update_daily_payout(jine) - - - szjilu_obj = Szjilu.query.get(id=1) - szjilu_obj.TotalIncome -= jine - szjilu_obj.TotalExpense += jine - szjilu_obj.DailyExpense += jine - szjilu_obj.save() - except Szjilu.DoesNotExist: - logger.error(f"平台退款:收支记录表(id=1)不存在,跳过") + from jituan.services.szjilu_accounting import apply_szjilu_expense + update_daily_payout(jine, getattr(dingdan_obj, 'ClubID', None)) + apply_szjilu_expense(jine, getattr(dingdan_obj, 'ClubID', None)) except Exception as e: logger.error(f"更新收支记录表异常: {str(e)}") @@ -6450,6 +6415,7 @@ class ShangjiaFakuanApplyView(APIView): Status=1, # 待缴纳 AffectsGrabbing=yingxiang_qiangdan, ApplicantIdentity=5, # 申请人是商家 + ClubID=resolve_penalty_club_id(order=order, penalized_user_id=dashou_id), ) logger.info(f"商家{shangjia_id}对订单{dingdan_id}打手{dashou_id}罚款成功,金额{fakuanjine}") return Response({'code': 0, 'msg': '罚款申请已提交'}) diff --git a/products/migrations/0003_czjilu_huiyuangoumai_club_id.py b/products/migrations/0003_czjilu_huiyuangoumai_club_id.py new file mode 100644 index 0000000..3e1ab74 --- /dev/null +++ b/products/migrations/0003_czjilu_huiyuangoumai_club_id.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0002_initial'), + ] + + operations = [ + migrations.AddField( + model_name='czjilu', + name='club_id', + field=models.CharField( + db_index=True, default='xq', max_length=16, verbose_name='所属俱乐部', + ), + ), + migrations.AddField( + model_name='huiyuangoumai', + name='club_id', + field=models.CharField( + db_index=True, default='xq', max_length=16, verbose_name='所属俱乐部', + ), + ), + ] diff --git a/products/migrations/0004_gsfenhong_club_id.py b/products/migrations/0004_gsfenhong_club_id.py new file mode 100644 index 0000000..fbc6262 --- /dev/null +++ b/products/migrations/0004_gsfenhong_club_id.py @@ -0,0 +1,18 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0003_czjilu_huiyuangoumai_club_id'), + ] + + operations = [ + migrations.AddField( + model_name='gsfenhong', + name='club_id', + field=models.CharField( + db_index=True, default='xq', max_length=16, verbose_name='所属俱乐部', + ), + ), + ] diff --git a/products/migrations/0005_duoci_fenhong_club_id.py b/products/migrations/0005_duoci_fenhong_club_id.py new file mode 100644 index 0000000..f399413 --- /dev/null +++ b/products/migrations/0005_duoci_fenhong_club_id.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0004_gsfenhong_club_id'), + ] + + operations = [ + migrations.AddField( + model_name='duocifenhong', + name='club_id', + field=models.CharField(db_index=True, default='xq', max_length=16, verbose_name='所属俱乐部'), + ), + migrations.AlterUniqueTogether( + name='duocifenhong', + unique_together={('club_id', 'huiyuan', 'yonghuid', 'cishu')}, + ), + ] diff --git a/products/models.py b/products/models.py index 7bd031e..45ea816 100644 --- a/products/models.py +++ b/products/models.py @@ -169,6 +169,9 @@ class Huiyuangoumai(QModel): daoqi_time = models.DateTimeField(verbose_name='会员到期时间') CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间') + club_id = models.CharField( + max_length=16, default='xq', db_index=True, verbose_name='所属俱乐部', + ) class Meta: db_table = 'huiyuangoumai' @@ -212,6 +215,9 @@ class Czjilu(QModel): jine = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='订单金额') leixing = models.IntegerField(null=True, blank=True, verbose_name='充值类型,1:会员,2:押金,3:积分,4:商家') shuoming = models.CharField(max_length=100, null=True, blank=True, verbose_name='订单说明') + club_id = models.CharField( + max_length=16, default='xq', db_index=True, verbose_name='所属俱乐部', + ) CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间') class Meta: @@ -249,8 +255,9 @@ class Gsfenhong(QModel): ) huiyuan_id = models.CharField(max_length=6, null=True, blank=True, verbose_name='会员ID') - CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') - UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间') + club_id = models.CharField( + max_length=16, default='xq', db_index=True, verbose_name='所属俱乐部', + ) class Meta: db_table = 'gsfenhong' @@ -275,6 +282,7 @@ class DuociFenhong(QModel): """ huiyuan = models.CharField(max_length=10, verbose_name='会员ID') yonghuid = models.CharField(max_length=7, db_index=True, verbose_name='管事ID') + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='所属俱乐部') cishu = models.PositiveIntegerField(verbose_name='分红次数') guanshi_fenhong = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name='管事分红金额') @@ -286,7 +294,7 @@ class DuociFenhong(QModel): db_table = 'duoci_fenhong' verbose_name = '多次分红配置' verbose_name_plural = '多次分红配置' - unique_together = ('huiyuan', 'yonghuid', 'cishu') # 同一管事、同一会员、同一次数唯一 + unique_together = ('club_id', 'huiyuan', 'yonghuid', 'cishu') indexes = [ models.Index(fields=['yonghuid']), diff --git a/products/views.py b/products/views.py index d6a5939..64aaf4a 100644 --- a/products/views.py +++ b/products/views.py @@ -51,9 +51,18 @@ from users.models import ( from users.business_models import User from orders.models import CommissionRate -from config.models import Szjilu, Gonggao, Lunbo +from config.models import Gonggao, Lunbo from orders.models import Penalty # 用于罚款类型 +from jituan.services.club_config import ( + get_commission_rate, + get_commission_rate_object, + get_huiyuan_price, + get_huiyuan_fenchong, +) +from jituan.services.club_context import resolve_club_id_from_request +from jituan.services.club_write import resolve_club_id_for_write +from jituan.services.club_penalty import resolve_gsfenhong_club_id import traceback @@ -258,6 +267,7 @@ class DashouHuiyuanList(APIView): }, status=status.HTTP_400_BAD_REQUEST) # 3. 查询所有会员数据(ORM查询,确保性能) + club_id = resolve_club_id_from_request(request) huiyuan_queryset = Huiyuan.query.all().only( 'huiyuan_id', # 会员ID 'jieshao', # 会员名字(注意:jieshao字段实际存储的是会员名字) @@ -271,7 +281,7 @@ class DashouHuiyuanList(APIView): huiyuan_list.append({ 'id': huiyuan.huiyuan_id, # 会员ID(对应前端id) 'mingzi': huiyuan.jieshao, # 会员名字(对应前端mingzi) - 'jiage': float(huiyuan.jiage), # 会员价格(转为浮点数) + 'jiage': float(get_huiyuan_price(club_id, huiyuan.huiyuan_id, huiyuan)), 'jieshao': huiyuan.jtjieshao # 会员介绍(对应前端jieshao) }) @@ -357,7 +367,8 @@ class YajinGoumai(APIView): yonghuid=request.user.UserUID, jine=jine, leixing=2, # 充值类型:2=押金 - shuoming=settings.YAJIN_PAY_DESCRIPTION + shuoming=settings.YAJIN_PAY_DESCRIPTION, + club_id=resolve_club_id_for_write(request, request.user), ) logger.info(f"创建押金充值订单: 用户{request.user.UserUID}, 订单{dingdanid}, 金额{jine}元") @@ -397,9 +408,12 @@ class YajinGoumai(APIView): def generate_wechat_pay_params(self, dingdanid, jine, openid, pay_type): """生成微信小程序支付参数""" # 获取配置 - APPID = settings.WEIXIN_APPID - MCHID = settings.WEIXIN_MCHID - KEY = settings.WEIXIN_SHANGHUMIYAO + from jituan.services.club_write import resolve_club_id_for_write + from jituan.services.wechat_pay import get_wechat_v2_config + pay_cfg = get_wechat_v2_config(resolve_club_id_for_write(self.request, request.user)) + APPID = pay_cfg['appid'] + MCHID = pay_cfg['mch_id'] + KEY = pay_cfg['key'] # 根据支付类型获取对应的配置 if pay_type == 'yajin': @@ -566,13 +580,16 @@ class YajinHuitiao(View): logger.info(f"回调数据解析: 订单{out_trade_no}, 状态{return_code}/{result_code}, 金额{total_fee}分") + from jituan.services.wechat_pay import club_id_from_czjilu, verify_wechat_v2_signature + notify_club = club_id_from_czjilu(out_trade_no) + # 2. 验证基本返回状态 if return_code != 'SUCCESS' or result_code != 'SUCCESS': logger.warning(f"微信支付回调状态异常: {return_code}/{result_code}") return self.wechat_response('FAIL', '支付失败') # 3. 验证签名(确保是微信发来的) - if not self.verify_signature(request_body): + if not verify_wechat_v2_signature(request_body, notify_club): logger.error(f"签名验证失败: {out_trade_no}") return self.wechat_response('FAIL', '签名验证失败') @@ -650,6 +667,11 @@ class YajinHuitiao(View): avatar=user_main.Avatar, nicheng=dashou.nicheng or '未知打手', fenhong_leixing=2, + club_id=resolve_gsfenhong_club_id( + dingdan_id=order.dingdan_id, + dashouid=order.yonghuid, + czjilu=order, + ), ) logger.info( f"押金分红成功: 订单{order.dingdan_id}, 管事{guanshi_id}, " @@ -684,25 +706,9 @@ class YajinHuitiao(View): jine_decimal = Decimal(str(order.jine)) if not isinstance(order.jine, Decimal) else order.jine # 查询或创建ID=1的收支记录 - update_daily_income(jine_decimal) - - szjilu, created = Szjilu.query.get_or_create( - id=1, - defaults={ - 'TotalIncome': Decimal('0.00'), - 'TotalFlow': Decimal('0.00'), - 'TotalExpense': Decimal('0.00'), - 'DailyExpense': Decimal('0.00'), - 'DailyFlow': Decimal('0.00') - } - ) - - # 更新三个字段 - szjilu.TotalIncome += jine_decimal # 总收益 - szjilu.TotalFlow += jine_decimal # 总流水 - szjilu.DailyFlow += jine_decimal # 今日流水 - - szjilu.save() + update_daily_income(jine_decimal, getattr(order, 'club_id', None)) + from jituan.services.szjilu_accounting import apply_szjilu_income + apply_szjilu_income(jine_decimal, getattr(order, 'club_id', None)) logger.info(f"收支记录更新成功: 订单{order.dingdan_id}, 金额{jine_decimal}元") @@ -953,7 +959,8 @@ class JifenBuchong(APIView): yonghuid=request.user.UserUID, jine=jine, leixing=3, # 充值类型:3=积分 - shuoming=settings.JIFEN_PAY_DESCRIPTION + shuoming=settings.JIFEN_PAY_DESCRIPTION, + club_id=resolve_club_id_for_write(request, request.user), ) logger.info(f"创建积分充值订单: 用户{request.user.UserUID}, 订单{dingdanid}, 金额{jine}元") @@ -993,9 +1000,12 @@ class JifenBuchong(APIView): def generate_wechat_pay_params(self, dingdanid, jine, openid, pay_type): """生成微信支付参数(完整版,无省略)""" # 微信支付配置 - 从settings中获取 - APPID = settings.WEIXIN_APPID - MCHID = settings.WEIXIN_MCHID - KEY = settings.WEIXIN_SHANGHUMIYAO + from jituan.services.club_write import resolve_club_id_for_write + from jituan.services.wechat_pay import get_wechat_v2_config + pay_cfg = get_wechat_v2_config(resolve_club_id_for_write(self.request, request.user)) + APPID = pay_cfg['appid'] + MCHID = pay_cfg['mch_id'] + KEY = pay_cfg['key'] # 根据支付类型获取对应的配置 if pay_type == 'yajin': @@ -1166,7 +1176,9 @@ class JifenHuitiao(View): return self.wechat_response('FAIL', '支付失败') # 3. 验证签名 - if not self.verify_signature(request_body): + from jituan.services.wechat_pay import club_id_from_czjilu, verify_wechat_v2_signature + notify_club = club_id_from_czjilu(out_trade_no) + if not verify_wechat_v2_signature(request_body, notify_club): logger.error(f"积分签名验证失败: {out_trade_no}") return self.wechat_response('FAIL', '签名验证失败') @@ -1198,26 +1210,11 @@ class JifenHuitiao(View): try: # 获取订单金额(确保是Decimal类型) jine_decimal = Decimal(str(order.jine)) if not isinstance(order.jine, Decimal) else order.jine - update_daily_income(jine_decimal) + update_daily_income(jine_decimal, getattr(order, 'club_id', None)) - # 查询或创建ID=1的收支记录 - szjilu, created = Szjilu.query.get_or_create( - id=1, - defaults={ - 'TotalIncome': Decimal('0.00'), - 'TotalFlow': Decimal('0.00'), - 'TotalExpense': Decimal('0.00'), - 'DailyExpense': Decimal('0.00'), - 'DailyFlow': Decimal('0.00') - } - ) - - # 更新三个字段 - szjilu.TotalIncome += jine_decimal # 总收益 - szjilu.TotalFlow += jine_decimal # 总流水 - szjilu.DailyFlow += jine_decimal # 今日流水 - - szjilu.save() + update_daily_income(jine_decimal, getattr(order, 'club_id', None)) + from jituan.services.szjilu_accounting import apply_szjilu_income + apply_szjilu_income(jine_decimal, getattr(order, 'club_id', None)) logger.info(f"收支记录更新成功: 订单{order.dingdan_id}, 金额{jine_decimal}元") @@ -1514,7 +1511,8 @@ class HuiyuanGoumai(APIView): dingdanid = f"CzHY{timestamp_ms:011d}{timestamp_ns:06d}{random_num:02d}" # 6. 创建充值记录订单 - jine = float(huiyuan.jiage) # 会员价格 + club_id = resolve_club_id_from_request(request) + jine = float(get_huiyuan_price(club_id, huiyuanid, huiyuan)) # 组合订单说明:会员介绍 + 配置描述 shuoming = f"{huiyuan.jieshao} - {settings.HUIYUAN_PAY_DESCRIPTION}" @@ -1525,7 +1523,8 @@ class HuiyuanGoumai(APIView): jine=jine, leixing=1, # 充值类型:1=会员 shuoming=shuoming, - huiyuan_id=huiyuanid # 存储会员ID + huiyuan_id=huiyuanid, # 存储会员ID + club_id=club_id, ) logger.info( @@ -1566,9 +1565,12 @@ class HuiyuanGoumai(APIView): def generate_wechat_pay_params(self, dingdanid, jine, openid, pay_type): """生成微信支付参数(完整版,无省略)""" # 微信支付配置 - 从settings中获取 - APPID = settings.WEIXIN_APPID - MCHID = settings.WEIXIN_MCHID - KEY = settings.WEIXIN_SHANGHUMIYAO + from jituan.services.club_write import resolve_club_id_for_write + from jituan.services.wechat_pay import get_wechat_v2_config + pay_cfg = get_wechat_v2_config(resolve_club_id_for_write(self.request, request.user)) + APPID = pay_cfg['appid'] + MCHID = pay_cfg['mch_id'] + KEY = pay_cfg['key'] # 根据支付类型获取对应的配置 if pay_type == 'yajin': @@ -1737,7 +1739,9 @@ class HuiyuanHuitiao(View): logger.warning(f"会员支付回调状态异常: {return_code}/{result_code}") return self.wechat_response('FAIL', '支付失败') - if not self.verify_signature(request_body): + from jituan.services.wechat_pay import club_id_from_czjilu, verify_wechat_v2_signature + notify_club = club_id_from_czjilu(out_trade_no) + if not verify_wechat_v2_signature(request_body, notify_club): logger.error(f"会员签名验证失败: {out_trade_no}") return self.wechat_response('FAIL', '签名验证失败') @@ -1774,17 +1778,9 @@ class HuiyuanHuitiao(View): try: jine_decimal = Decimal(str(order.jine)) if not isinstance(order.jine, Decimal) else order.jine - update_daily_income(jine_decimal) - - szjilu, _ = Szjilu.query.get_or_create( - id=1, - defaults={'TotalIncome': Decimal('0.00'), 'TotalFlow': Decimal('0.00'), 'TotalExpense': Decimal('0.00'), - 'DailyExpense': Decimal('0.00'), 'DailyFlow': Decimal('0.00')} - ) - szjilu.TotalIncome += jine_decimal - szjilu.TotalFlow += jine_decimal - szjilu.DailyFlow += jine_decimal - szjilu.save() + update_daily_income(jine_decimal, getattr(order, 'club_id', None)) + from jituan.services.szjilu_accounting import apply_szjilu_income + apply_szjilu_income(jine_decimal, getattr(order, 'club_id', None)) logger.info(f"收支记录更新成功: 订单{order.dingdan_id}, 金额{jine_decimal}元") except Exception as e: logger.error(f"更新收支记录失败: {str(e)}", exc_info=True) @@ -1851,7 +1847,8 @@ class HuiyuanHuitiao(View): huiyuan_id=huiyuan_id, jieshao=huiyuan.jieshao, huiyuan_zhuangtai=1, - daoqi_time=timezone.now() + timedelta(days=30) + daoqi_time=timezone.now() + timedelta(days=30), + club_id=get_user_club_id(User.query.filter(UserUID=yonghuid).first()), ) logger.info(f"新购会员: 用户{yonghuid}, 会员{huiyuan_id}") return goumai_record, True, is_first_buy_any @@ -1911,9 +1908,13 @@ class HuiyuanHuitiao(View): # 获取订单金额(用于限制分红总额) order_amount = Decimal(str(order.jine)) if not isinstance(order.jine, Decimal) else order.jine + club_id = getattr(order, 'club_id', None) or resolve_club_id_for_write(user=dashou.user) + guanshi_fc, zuzhang_fc = get_huiyuan_fenchong(club_id, huiyuan.huiyuan_id, huiyuan) # 计算管事分红金额 - guanshi_fenhong = self._calc_guanshi_fenhong(guanshi, huiyuan, guanshi_id, cishu) + guanshi_fenhong = self._calc_guanshi_fenhong( + guanshi, huiyuan, guanshi_id, cishu, default_fc=guanshi_fc, + ) if guanshi_fenhong is None: logger.info(f"管事{guanshi_id}本次无分红") guanshi_fenhong = Decimal('0.00') @@ -1928,7 +1929,9 @@ class HuiyuanHuitiao(View): logger.warning(f"组长信息不存在: {zuzhang_id}") zuzhang = None - zuzhang_fenhong = self._calc_zuzhang_fenhong(zuzhang, huiyuan, zuzhang_id, cishu) + zuzhang_fenhong = self._calc_zuzhang_fenhong( + zuzhang, huiyuan, zuzhang_id, cishu, default_fc=zuzhang_fc, + ) if zuzhang_fenhong is None: logger.info(f"组长{zuzhang_id}本次无分红") zuzhang_fenhong = Decimal('0.00') @@ -1975,11 +1978,15 @@ class HuiyuanHuitiao(View): fenhong=guanshi_fenhong, avatar=avatar, nicheng=nicheng, - # 新增字段 zuzhang_id=zuzhang_id if zuzhang_fenhong > 0 else None, zuzhang_fenhong=zuzhang_fenhong if zuzhang_fenhong > 0 else None, huiyuan_id=huiyuan.huiyuan_id, fenhong_leixing=1, + club_id=resolve_gsfenhong_club_id( + dingdan_id=order.dingdan_id, + dashouid=order.yonghuid, + czjilu=order, + ), ) @@ -2028,22 +2035,24 @@ class HuiyuanHuitiao(View): logger.error(f"处理分红失败: {str(e)}", exc_info=True) # 不抛出异常,避免影响主流程 - def _calc_guanshi_fenhong(self, guanshi, huiyuan, guanshi_id, cishu): + def _calc_guanshi_fenhong(self, guanshi, huiyuan, guanshi_id, cishu, default_fc=None): """计算管事分红金额""" # 先查多次分红配置表 try: + cid = resolve_gsfenhong_club_id(dashouid=guanshi_id) config = DuociFenhong.query.get( + club_id=cid, huiyuan=huiyuan, yonghuid=guanshi_id, - cishu=cishu + cishu=cishu, ) return Decimal(str(config.guanshi_fenhong)) except DuociFenhong.DoesNotExist: pass if cishu == 1: - # 第一次分红,取会员表默认金额 - return Decimal(str(huiyuan.guanshifc)) + base = default_fc if default_fc is not None else huiyuan.guanshifc + return Decimal(str(base)) else: # 第二次及以上,检查是否开启二次分红 if guanshi.fenghong_erci_enabled: @@ -2051,24 +2060,26 @@ class HuiyuanHuitiao(View): else: return None # 无分红 - def _calc_zuzhang_fenhong(self, zuzhang, huiyuan, zuzhang_id, cishu): + def _calc_zuzhang_fenhong(self, zuzhang, huiyuan, zuzhang_id, cishu, default_fc=None): """计算组长分红金额""" if zuzhang is None: return None # 查多次分红配置表 try: + cid = resolve_gsfenhong_club_id(dashouid=zuzhang_id) config = DuociFenhong.query.get( + club_id=cid, huiyuan=huiyuan, yonghuid=zuzhang_id, - cishu=cishu + cishu=cishu, ) return Decimal(str(config.zuzhang_fenhong)) except DuociFenhong.DoesNotExist: pass if cishu == 1: - # 第一次分红,取会员表默认组长分成 - return Decimal(str(huiyuan.zuzhangfc)) + base = default_fc if default_fc is not None else huiyuan.zuzhangfc + return Decimal(str(base)) else: # 第二次及以上,检查组长是否开启额外分红 if zuzhang.kaioi_ewai_fenhong: @@ -2358,7 +2369,8 @@ class ShangjiaChongzhi(APIView): yonghuid=request.user.UserUID, jine=jine, leixing=4, # 充值类型:4=商家充值 - shuoming=settings.SHANGJIA_CZ_PAY_DESCRIPTION + shuoming=settings.SHANGJIA_CZ_PAY_DESCRIPTION, + club_id=resolve_club_id_for_write(request, request.user), ) logger.info(f"创建商家充值订单: 商家{shangjia.nicheng}, 订单{dingdanid}, 金额{jine}元") except Exception as e: @@ -2404,9 +2416,12 @@ class ShangjiaChongzhi(APIView): def generate_wechat_pay_params(self, dingdanid, jine, openid, pay_type): """生成微信小程序支付参数""" # 获取配置 - APPID = settings.WEIXIN_APPID - MCHID = settings.WEIXIN_MCHID - KEY = settings.WEIXIN_SHANGHUMIYAO + from jituan.services.club_write import resolve_club_id_for_write + from jituan.services.wechat_pay import get_wechat_v2_config + pay_cfg = get_wechat_v2_config(resolve_club_id_for_write(self.request, request.user)) + APPID = pay_cfg['appid'] + MCHID = pay_cfg['mch_id'] + KEY = pay_cfg['key'] # 根据支付类型获取对应的配置 if pay_type == 'shangjia': @@ -2573,7 +2588,9 @@ class ShangjiaHuitiao(View): return self.wechat_response('FAIL', '支付失败') # 3. 验证签名(确保是微信发来的) - if not self.verify_signature(request_body): + from jituan.services.wechat_pay import club_id_from_czjilu, verify_wechat_v2_signature + notify_club = club_id_from_czjilu(out_trade_no) + if not verify_wechat_v2_signature(request_body, notify_club): logger.error(f"商家支付签名验证失败: {out_trade_no}") return self.wechat_response('FAIL', '签名验证失败') @@ -2606,26 +2623,11 @@ class ShangjiaHuitiao(View): # 获取订单金额(确保是Decimal类型) jine_decimal = Decimal(str(order.jine)) if not isinstance(order.jine, Decimal) else order.jine - update_daily_income(jine_decimal) + update_daily_income(jine_decimal, getattr(order, 'club_id', None)) - # 查询或创建ID=1的收支记录 - szjilu, created = Szjilu.query.get_or_create( - id=1, - defaults={ - 'TotalIncome': Decimal('0.00'), - 'TotalFlow': Decimal('0.00'), - 'TotalExpense': Decimal('0.00'), - 'DailyExpense': Decimal('0.00'), - 'DailyFlow': Decimal('0.00') - } - ) - - # 更新三个字段 - szjilu.TotalIncome += jine_decimal # 总收益 - szjilu.TotalFlow += jine_decimal # 总流水 - szjilu.DailyFlow += jine_decimal # 今日流水 - - szjilu.save() + update_daily_income(jine_decimal, getattr(order, 'club_id', None)) + from jituan.services.szjilu_accounting import apply_szjilu_income + apply_szjilu_income(jine_decimal, getattr(order, 'club_id', None)) logger.info(f"收支记录更新成功: 订单{order.dingdan_id}, 金额{jine_decimal}元") @@ -4361,13 +4363,10 @@ class AdShangpinHuQu(APIView): status=status.HTTP_401_UNAUTHORIZED ) - # 6. 查询公告数据(类型=1的公告) - gonggao_obj = Gonggao.query.filter(NoticeType=1).first() - shangpingonggao = gonggao_obj.Content if gonggao_obj else '' - - # 7. 查询轮播图数据(类型=1的轮播图) - lunbo_objs = Lunbo.query.filter(ImageType=1).values('ImageURL') - shangpinlunbo = [item['ImageURL'] for item in lunbo_objs] + # 6. 查询公告与轮播(按俱乐部,默认抢单池) + from jituan.services.display_config import get_gonggao_content, get_lunbo_urls + shangpingonggao = get_gonggao_content(request, notice_type=1) + shangpinlunbo = get_lunbo_urls(request, page_key='order_pool', image_type=1) # 8. 查询商品类型数据 shangpinleixing_data = ShangpinLeixing.query.all().values( @@ -6077,7 +6076,8 @@ class CzhqdyView(APIView): # 会员 try: huiyuan = Huiyuan.query.get(huiyuan_id=huiyuan_id) - target_price = huiyuan.jiage + club_id = resolve_club_id_from_request(request) + target_price = get_huiyuan_price(club_id, huiyuan_id, huiyuan) except Huiyuan.DoesNotExist: return Response({'code': 404, 'msg': '会员不存在'}, status=status.HTTP_404_NOT_FOUND) elif leixing == 2: @@ -6096,12 +6096,9 @@ class CzhqdyView(APIView): # 获取所有费率(打手佣金、管事分红、组长分红、打手押金) rates = {} + club_id = resolve_club_id_from_request(request) for key in ['5', '6', '8', '11']: - try: - rate_obj = CommissionRate.query.get(Platform=key) - rates[key] = rate_obj.Rate - except CommissionRate.DoesNotExist: - rates[key] = None # 未配置则跳过 + rates[key] = get_commission_rate(club_id, key) # 构建可用身份列表 available_list = [] @@ -6214,10 +6211,11 @@ class DsqrgmdhView(APIView): return Response({'code': 400, 'msg': 'yajin_jine 必须为正数'}, status=status.HTTP_400_BAD_REQUEST) target_price = None + club_id = resolve_club_id_from_request(request) if leixing == 1: try: huiyuan = Huiyuan.query.get(huiyuan_id=huiyuan_id) - target_price = huiyuan.jiage + target_price = get_huiyuan_price(club_id, huiyuan_id, huiyuan) except Huiyuan.DoesNotExist: return Response({'code': 404, 'msg': '会员不存在'}, status=status.HTTP_404_NOT_FOUND) elif leixing == 2: @@ -6302,6 +6300,7 @@ class DsqrgmdhView(APIView): jine=required, leixing=leixing, shuoming=shuoming, + club_id=club_id, ) response_data = {} @@ -6394,7 +6393,8 @@ class DsqrgmdhView(APIView): huiyuan_id=huiyuan_id, jieshao=huiyuan_obj.jieshao, huiyuan_zhuangtai=1, - daoqi_time=timezone.now() + timedelta(days=30) + daoqi_time=timezone.now() + timedelta(days=30), + club_id=get_user_club_id(User.query.filter(UserUID=yonghuid).first()), ) return record, is_first_buy_any except Exception as e: diff --git a/shop/views.py b/shop/views.py index be095d3..35264b0 100644 --- a/shop/views.py +++ b/shop/views.py @@ -2611,7 +2611,7 @@ class ShopRefundView(APIView): # 更新平台支出记录 try: - update_daily_payout(order.Amount) + update_daily_payout(order.Amount, getattr(order, 'ClubID', None)) except Exception as e: logger.error(f"更新每日支出失败: {e}") diff --git a/users/business_models.py b/users/business_models.py index 37cbbc1..836ab64 100644 --- a/users/business_models.py +++ b/users/business_models.py @@ -43,6 +43,10 @@ class User(_UserBase): IP = models.CharField(max_length=50, db_column='IP', verbose_name='来访用户IP', null=True, blank=True) IsStaff = models.BooleanField(default=False, db_column='IsStaff', verbose_name='是否员工') IsSuperuser = models.BooleanField(default=False, db_column='IsSuperuser', verbose_name='是否超级用户') + ClubID = models.CharField( + max_length=16, default='xq', db_column='ClubID', verbose_name='所属俱乐部', + null=True, blank=True, + ) objects = UserManager() diff --git a/users/fadan_fenhong_utils.py b/users/fadan_fenhong_utils.py index 994efdf..d8b44b6 100644 --- a/users/fadan_fenhong_utils.py +++ b/users/fadan_fenhong_utils.py @@ -64,7 +64,7 @@ def process_fadan_fenhong(fadan_id): return True, "罚款金额为0,无分红" # 5. 确定分红利率 - rate = _get_fenhong_rate(shenqing_chufa, fenhong_shenfen, lilv_shenfen) + rate = _get_fenhong_rate(shenqing_chufa, fenhong_shenfen, lilv_shenfen, club_id=fadan.ClubID) if rate is None: logger.warning(f"未找到身份 {src_shenfen} (映射后 fenhong_shenfen={fenhong_shenfen}, lilv_shenfen={lilv_shenfen}) 的分红利率,罚单 {fadan_id} 不进行分红") fadan.ApplicantBonusAmount = Decimal('0.00') @@ -79,6 +79,8 @@ def process_fadan_fenhong(fadan_id): fadan.save(update_fields=['ApplicantBonusAmount']) return True, "分红金额为0" + club_id = fadan.ClubID or 'xq' + # 7. 根据申请处罚者身份执行不同的分红逻辑 # src_shenfen == 5 代表商家(源身份) if src_shenfen == 5: @@ -87,6 +89,7 @@ def process_fadan_fenhong(fadan_id): fenhong_record, created = PenaltyBonus.objects.get_or_create( BeneficiaryID=shenqing_chufa, Identity=fenhong_shenfen, + ClubID=club_id, defaults={ 'TotalAmount': Decimal('0.00'), 'WithdrawableAmount': Decimal('0.00'), @@ -120,6 +123,7 @@ def process_fadan_fenhong(fadan_id): fenhong_record, created = PenaltyBonus.objects.get_or_create( BeneficiaryID=shenqing_chufa, Identity=fenhong_shenfen, + ClubID=club_id, defaults={ 'TotalAmount': Decimal('0.00'), 'WithdrawableAmount': Decimal('0.00'), @@ -145,7 +149,7 @@ def process_fadan_fenhong(fadan_id): return False, f"分红处理异常: {str(e)}" -def _get_fenhong_rate(fenhongzhe_id, fenhong_shenfen, lilv_shenfen): +def _get_fenhong_rate(fenhongzhe_id, fenhong_shenfen, lilv_shenfen, club_id='xq'): """ 获取分红利率(优先使用个人单独配置,否则使用全局身份配置) """ @@ -153,6 +157,7 @@ def _get_fenhong_rate(fenhongzhe_id, fenhong_shenfen, lilv_shenfen): individual = PenaltyBonus.objects.get( BeneficiaryID=fenhongzhe_id, Identity=fenhong_shenfen, + ClubID=club_id, is_individual_rate=True ) return individual.IndividualRate diff --git a/users/migrations/0002_user_clubid.py b/users/migrations/0002_user_clubid.py new file mode 100644 index 0000000..e3b81ad --- /dev/null +++ b/users/migrations/0002_user_clubid.py @@ -0,0 +1,20 @@ +"""users: User.ClubID 字段""" +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='ClubID', + field=models.CharField( + blank=True, db_column='ClubID', default='xq', + max_length=16, null=True, verbose_name='所属俱乐部', + ), + ), + ] diff --git a/users/migrations/0003_tixian_club_id.py b/users/migrations/0003_tixian_club_id.py new file mode 100644 index 0000000..62aa53c --- /dev/null +++ b/users/migrations/0003_tixian_club_id.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_user_clubid'), + ] + + operations = [ + migrations.AddField( + model_name='tixianautorecord', + name='club_id', + field=models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID'), + ), + migrations.AddField( + model_name='tixianjilu', + name='club_id', + field=models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID'), + ), + migrations.AddField( + model_name='tixianshenhejilu', + name='club_id', + field=models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID'), + ), + migrations.AddIndex( + model_name='tixianjilu', + index=models.Index(fields=['club_id', 'zhuangtai'], name='tixianjilu_club_status_idx'), + ), + ] diff --git a/users/models.py b/users/models.py index 8791af7..5d92131 100644 --- a/users/models.py +++ b/users/models.py @@ -340,6 +340,7 @@ class UserKefu(QModel): class Tixianjilu(QModel): id = models.AutoField(primary_key=True, verbose_name='提现记录ID') + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID') yonghuid = models.CharField(max_length=7, db_index=True, verbose_name='提现用户ID') avatar = models.CharField(max_length=500, null=True, blank=True, verbose_name='头像') # 用CharField phone = models.CharField(max_length=11, blank=True, null=True, verbose_name='手机号') @@ -370,6 +371,7 @@ class Tixianjilu(QModel): verbose_name = '提现记录表' verbose_name_plural = verbose_name indexes = [ + models.Index(fields=['club_id', 'zhuangtai']), models.Index(fields=['yonghuid']), models.Index(fields=['zhuangtai']), models.Index(fields=['shenheid']), @@ -393,6 +395,7 @@ class TixianShenheJilu(QModel): shenhe_danhao = models.CharField( max_length=32, unique=True, db_index=True, verbose_name='审核单号' ) + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID') yonghuid = models.CharField(max_length=7, db_index=True, verbose_name='用户ID') # 提现类型:1打手佣金 2管事分红 3组长分红 4考核官分佣 5打手押金 6商家余额 leixing = models.PositiveSmallIntegerField(db_index=True, verbose_name='提现类型') @@ -454,6 +457,7 @@ class TixianAutoRecord(QModel): db_index=True, verbose_name='提现订单号(自定义)' ) + club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID') yonghuid = models.CharField(max_length=7, db_index=True, verbose_name='用户ID') leixing = models.IntegerField(verbose_name='提现类型 1打手 2管事') jine = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='申请提现金额') diff --git a/users/tixian_shenhe_services.py b/users/tixian_shenhe_services.py index 9249838..7bdbccb 100644 --- a/users/tixian_shenhe_services.py +++ b/users/tixian_shenhe_services.py @@ -25,8 +25,15 @@ from django.conf import settings from django.db import transaction from django.utils import timezone -from orders.models import Order, MerchantOrderExt, Penalty, CommissionRate -from config.models import Szjilu, TixianQuotaDefault +from orders.models import Order, MerchantOrderExt, Penalty +from jituan.services.wechat_pay import club_id_for_tixian_yonghuid +from jituan.services.withdraw_config import ( + get_or_create_platform_daily_stat, + get_personal_daily_quota, + get_platform_daily_limit, + get_platform_daily_total, + get_tixian_feilv as _get_tixian_feilv_for_club, +) from utils.fadan_utils import check_fadan_qiangdan_eligible @@ -67,6 +74,19 @@ LEIXING_RATE_KEY = { 6: '10', } +def _club_id_for_user(user_main): + return club_id_for_tixian_yonghuid(user_main.UserUID) + + +def get_tixian_feilv(leixing, yonghuid=None, club_id=None): + """按用户所属俱乐部读取提现手续费率。""" + if club_id is None: + if not yonghuid: + raise ValueError('缺少用户或俱乐部') + club_id = club_id_for_tixian_yonghuid(yonghuid) + return _get_tixian_feilv_for_club(club_id, leixing) + + DAKUAN_MODE_MANUAL = 1 DAKUAN_MODE_AUTO = 2 @@ -149,16 +169,6 @@ def generate_shenhe_danhao(): raise RuntimeError('生成审核单号失败,请重试') -def get_tixian_feilv(leixing): - rate_key = LEIXING_RATE_KEY.get(leixing) - if not rate_key: - raise ValueError('无效的提现类型') - rate_obj = CommissionRate.query.filter(Platform=rate_key).first() - if not rate_obj: - raise ValueError(f'提现费率未配置(类型{leixing})') - return decimal.Decimal(str(rate_obj.Rate)) - - def calc_fee_amounts(jine, feilv): """申请金额 jine;实际到账 = jine - 手续费""" shouxufei = (jine * feilv).quantize(decimal.Decimal('0.01')) @@ -410,25 +420,8 @@ def validate_collect_eligibility(user_main, leixing): return False, '无效的提现类型' -def _get_personal_daily_quota(leixing, profile): - """打手/管事/组长个人每日限额:额外开启优先,否则默认配置""" - if leixing == 1: - if profile.kaioi_ewai_xiane and profile.ewai_xiane > 0: - return profile.ewai_xiane - return TixianQuotaDefault.query.get(UserType=1).default_quota - if leixing == 2: - if profile.kaioi_ewai_xiane and profile.ewai_xiane > 0: - return profile.ewai_xiane - return TixianQuotaDefault.query.get(UserType=2).default_quota - if leixing == 3: - try: - default_quota = TixianQuotaDefault.query.get(UserType=3).default_quota - except TixianQuotaDefault.DoesNotExist: - default_quota = decimal.Decimal('0.00') - if profile.kaioi_ewai_tixian and profile.ewai_tixian_xiane > 0: - return profile.ewai_tixian_xiane - return default_quota - raise ValueError('无效的个人限额身份') +def _get_personal_daily_quota(club_id, leixing, profile): + return get_personal_daily_quota(club_id, leixing, profile) def _get_active_auto_record(shenhe_danhao): @@ -444,23 +437,12 @@ def _get_active_auto_record(shenhe_danhao): ) -def _get_platform_daily_total(leixing, today=None): - """平台今日已提现总额:只读 houtai.WithdrawalDailyStats(到账金额口径)""" - if today is None: - today = date.today() - try: - return WithdrawalDailyStats.query.get(Date=today, WithdrawalType=leixing).total_amount - except WithdrawalDailyStats.DoesNotExist: - return decimal.Decimal('0.00') +def _get_platform_daily_total(club_id, leixing, today=None): + return get_platform_daily_total(club_id, leixing, today) -def _calc_platform_projected(leixing, shijidaozhang, shenhe_danhao=''): - """ - 平台今日预占后总额(收款前只读预估)。 - 本审核单若已有进行中打款记录,其金额尚未写入统计表,但不应重复加计。 - 返回 (daily_used, projected_total, additional_amount) - """ - daily_used = _get_platform_daily_total(leixing) +def _calc_platform_projected(club_id, leixing, shijidaozhang, shenhe_danhao=''): + daily_used = _get_platform_daily_total(club_id, leixing) active_rec = _get_active_auto_record(shenhe_danhao) if active_rec: return daily_used, daily_used, decimal.Decimal('0.00') @@ -475,20 +457,14 @@ def _projected_personal_total(personal_today, jine, shenhe_danhao=''): return personal_today - already_counted + jine -def _get_platform_daily_limit(leixing): - """ - 平台每日总限额(peizhi.TixianQuotaDefault): - 提现身份1打手→配置4,2管事→5,3组长→6,4考核官→7,5押金→8,6商家→9 - """ - quota_leixing = leixing + 3 - try: - return TixianQuotaDefault.query.get(UserType=quota_leixing).default_quota - except TixianQuotaDefault.DoesNotExist: +def _get_platform_daily_limit(club_id, leixing): + limit = get_platform_daily_limit(club_id, leixing) + if limit <= 0: logger.warning( - '平台每日总限额未配置 leixing=%s(读取quota_leixing=%s),按0处理禁止新增收款', - leixing, quota_leixing, + '平台每日总限额为0 club_id=%s leixing=%s', + club_id, leixing, ) - return decimal.Decimal('0.00') + return limit def _check_personal_quota_readonly(user_main, leixing, jine, shenhe_danhao=''): @@ -525,7 +501,7 @@ def _check_personal_quota_readonly(user_main, leixing, jine, shenhe_danhao=''): else: personal_today = decimal.Decimal('0.00') - personal_quota = _get_personal_daily_quota(leixing, profile) + personal_quota = _get_personal_daily_quota(_club_id_for_user(user_main), leixing, profile) projected_personal = _projected_personal_total(personal_today, jine, shenhe_danhao) if projected_personal > personal_quota: return ( @@ -536,22 +512,22 @@ def _check_personal_quota_readonly(user_main, leixing, jine, shenhe_danhao=''): return True, '', '' -def _check_platform_quota_readonly(leixing, shijidaozhang, shenhe_danhao=''): +def _check_platform_quota_readonly(club_id, leixing, shijidaozhang, shenhe_danhao=''): """平台每日总限额只读校验:今日已用 + 本笔到账 > 总限额 则拒绝""" - platform_limit = _get_platform_daily_limit(leixing) + platform_limit = _get_platform_daily_limit(club_id, leixing) shijidaozhang = shijidaozhang or decimal.Decimal('0.00') if platform_limit <= 0 and shijidaozhang > 0: return False, '今日该角色提现已关闭(总限额为0),暂无法收款', 'platform' daily_used, projected, additional = _calc_platform_projected( - leixing, shijidaozhang, shenhe_danhao, + club_id, leixing, shijidaozhang, shenhe_danhao, ) if projected > platform_limit: logger.warning( - '平台总限额拦截 leixing=%s shenhe_danhao=%s daily_used=%s additional=%s ' + '平台总限额拦截 club_id=%s leixing=%s shenhe_danhao=%s daily_used=%s additional=%s ' 'projected=%s limit=%s', - leixing, shenhe_danhao, daily_used, additional, projected, platform_limit, + club_id, leixing, shenhe_danhao, daily_used, additional, projected, platform_limit, ) return ( False, @@ -570,7 +546,8 @@ def check_collect_quota_limits(user_main, leixing, jine, shijidaozhang, shenhe_d ok, msg, kind = _check_personal_quota_readonly(user_main, leixing, jine, shenhe_danhao) if not ok: return False, msg, kind - return _check_platform_quota_readonly(leixing, shijidaozhang, shenhe_danhao) + club_id = _club_id_for_user(user_main) + return _check_platform_quota_readonly(club_id, leixing, shijidaozhang, shenhe_danhao) def reserve_collect_quota_limits(user_main, leixing, jine, shijidaozhang, shenhe_danhao=''): @@ -583,6 +560,7 @@ def reserve_collect_quota_limits(user_main, leixing, jine, shijidaozhang, shenhe today = date.today() shijidaozhang = shijidaozhang or decimal.Decimal('0.00') + club_id = _club_id_for_user(user_main) if _get_active_auto_record(shenhe_danhao): return True, '', '' @@ -609,7 +587,7 @@ def reserve_collect_quota_limits(user_main, leixing, jine, shijidaozhang, shenhe personal_profile.jinri_tixian = decimal.Decimal('0.00') personal_today = personal_profile.jinri_tixian - personal_quota = _get_personal_daily_quota(leixing, personal_profile) + personal_quota = _get_personal_daily_quota(club_id, leixing, personal_profile) if personal_today + jine > personal_quota: return ( False, @@ -617,12 +595,8 @@ def reserve_collect_quota_limits(user_main, leixing, jine, shijidaozhang, shenhe 'personal', ) - platform_stat, _ = WithdrawalDailyStats.objects.select_for_update().get_or_create( - Date=today, - WithdrawalType=leixing, - defaults={'total_amount': decimal.Decimal('0.00')}, - ) - platform_limit = _get_platform_daily_limit(leixing) + platform_stat = get_or_create_platform_daily_stat(club_id, leixing, today) + platform_limit = _get_platform_daily_limit(club_id, leixing) if platform_limit <= 0 and shijidaozhang > 0: return False, '今日该角色提现已关闭(总限额为0),暂无法收款', 'platform' if platform_stat.total_amount + shijidaozhang > platform_limit: @@ -659,6 +633,7 @@ def release_collect_quota_reservation(user_main, leixing, jine, shijidaozhang): """微信打款明确失败/用户取消时回滚收款预占的限额""" today = date.today() shijidaozhang = shijidaozhang or decimal.Decimal('0.00') + club_id = _club_id_for_user(user_main) try: with transaction.atomic(): if leixing == 1: @@ -679,7 +654,7 @@ def release_collect_quota_reservation(user_main, leixing, jine, shijidaozhang): try: stat = WithdrawalDailyStats.objects.select_for_update().get( - Date=today, WithdrawalType=leixing, + club_id=club_id, Date=today, WithdrawalType=leixing, ) stat.total_amount = max( stat.total_amount - shijidaozhang, @@ -727,8 +702,10 @@ def _quota_already_reserved_for_record(auto_record): today = date.today() shijidaozhang = auto_record.shijidaozhang or decimal.Decimal('0.00') - stat_total = _get_platform_daily_total(auto_record.leixing, today) + club_id = club_id_for_tixian_yonghuid(auto_record.yonghuid) + stat_total = _get_platform_daily_total(club_id, auto_record.leixing, today) other_sum = TixianAutoRecord.query.filter( + club_id=club_id, leixing=auto_record.leixing, zhuangtai__in=[0, 1], CreateTime__date=today, @@ -758,11 +735,8 @@ def apply_transfer_success_quota(auto_record): logger.error('打款成功累加限额失败:用户不存在 %s', auto_record.yonghuid) return - platform_stat, _ = WithdrawalDailyStats.objects.select_for_update().get_or_create( - Date=today, - WithdrawalType=leixing, - defaults={'total_amount': decimal.Decimal('0.00')}, - ) + club_id = _club_id_for_user(user_main) + platform_stat = get_or_create_platform_daily_stat(club_id, leixing, today) platform_stat.total_amount += shijidaozhang platform_stat.save(update_fields=['total_amount']) @@ -981,19 +955,28 @@ def close_audit_over_limit_refund(audit, auto_record=None, fail_reason='单笔 return True -def query_wechat_transfer_bill(out_bill_no): +def query_wechat_transfer_bill(out_bill_no, club_id=None): """ 向微信查转账单状态 GET /v3/fund-app/mch-transfer/transfer-bills/out-bill-no/{out_bill_no} 返回 dict;网络/系统异常返回 None;微信无此单返回 {'_not_found': True} """ + from jituan.services.wechat_pay import club_id_for_tixian_yonghuid + + if not club_id: + try: + rec = TixianAutoRecord.query.filter(tixian_id=out_bill_no).first() + if rec: + club_id = club_id_for_tixian_yonghuid(rec.yonghuid) + except Exception: + pass url = ( 'https://api.mch.weixin.qq.com/v3/fund-app/mch-transfer/' f'transfer-bills/out-bill-no/{out_bill_no}' ) try: - auth = build_authorization('GET', url, '') + auth = build_authorization('GET', url, '', club_id=club_id) headers = { 'Authorization': auth, 'Accept': 'application/json', @@ -1030,21 +1013,11 @@ def _parse_package_info(raw): def _apply_platform_accounting(auto_record): """平台收支统计(幂等由 mark_transfer_success 外层保证只调一次)""" - - update_daily_payout(auto_record.shijidaozhang) - try: - szjilu = Szjilu.objects.select_for_update().get(id=1) - szjilu.TotalIncome -= auto_record.shijidaozhang - szjilu.TotalExpense += auto_record.shijidaozhang - szjilu.DailyExpense += auto_record.shijidaozhang - szjilu.save() - except Szjilu.DoesNotExist: - Szjilu.query.create( - id=1, - TotalIncome=-auto_record.shijidaozhang, - TotalExpense=auto_record.shijidaozhang, - DailyExpense=auto_record.shijidaozhang, - ) + from jituan.services.szjilu_accounting import apply_szjilu_expense + from jituan.services.wechat_pay import club_id_for_tixian_yonghuid + payout_club = club_id_for_tixian_yonghuid(auto_record.yonghuid) + update_daily_payout(auto_record.shijidaozhang, payout_club) + apply_szjilu_expense(auto_record.shijidaozhang, payout_club) def mark_transfer_success(auto_record, *, wechat_transfer_no=None, with_accounting=True): @@ -1210,7 +1183,7 @@ def reconcile_shenhe_wechat_bills(shenhe_danhao): if rec.zhuangtai == 1: return RECONCILE_COMPLETED, {'msg': '该提现已完成,请勿重复操作'} - wx_data = query_wechat_transfer_bill(rec.tixian_id) + wx_data = query_wechat_transfer_bill(rec.tixian_id, club_id_for_tixian_yonghuid(rec.yonghuid)) if wx_data is None: logger.error( '对账查单不可用 shenhe_danhao=%s bill=%s', @@ -1316,7 +1289,7 @@ def handle_post_transfer_failure(tixian_id, shenhe_danhao, err_code='', err_msg= except TixianAutoRecord.DoesNotExist: return RECONCILE_PENDING, '收款处理中,请稍后再试' - wx_data = query_wechat_transfer_bill(tixian_id) + wx_data = query_wechat_transfer_bill(tixian_id, club_id_for_tixian_yonghuid(ar.yonghuid)) if wx_data is None: logger.error( '发起转账失败且查单不可用 bill=%s code=%s msg=%s,保持待查单', @@ -1377,7 +1350,7 @@ def create_audit_application(user_main, leixing, jine): if not ok: raise ValueError(msg) - feilv = get_tixian_feilv(leixing) + feilv = get_tixian_feilv(leixing, yonghuid=yonghuid) shouxufei, shijidaozhang = calc_fee_amounts(jine, feilv) if shijidaozhang <= 0: raise ValueError('提现金额过低,扣除手续费后无实际到账') @@ -1391,9 +1364,11 @@ def create_audit_application(user_main, leixing, jine): with transaction.atomic(): nicheng = deduct_balance(user_main, leixing, jine) or nicheng + club_id = club_id_for_tixian_yonghuid(yonghuid) audit_kwargs = { 'shenhe_danhao': shenhe_danhao, + 'club_id': club_id, 'yonghuid': yonghuid, 'leixing': leixing, 'zhuangtai': 1, @@ -1407,6 +1382,7 @@ def create_audit_application(user_main, leixing, jine): audit = TixianShenheJilu.query.create(**audit_kwargs) tixian_record = Tixianjilu.query.create( + club_id=club_id, yonghuid=yonghuid, avatar=user_main.Avatar or '', phone=user_main.Phone or '', diff --git a/users/tixian_shenhe_views.py b/users/tixian_shenhe_views.py index dd33b0e..b4c6a3e 100644 --- a/users/tixian_shenhe_views.py +++ b/users/tixian_shenhe_views.py @@ -120,10 +120,10 @@ def _build_wx_transfer_body(wx_cfg, tixian_id, openid, yonghuid, leixing, shijid } -def _call_wechat_transfer(body, wx_cfg): +def _call_wechat_transfer(body, wx_cfg, club_id=None): url = 'https://api.mch.weixin.qq.com/v3/fund-app/mch-transfer/transfer-bills' body_json = json.dumps(body, separators=(',', ':')) - auth = build_authorization('POST', url, body_json) + auth = build_authorization('POST', url, body_json, club_id=club_id) headers = { 'Authorization': auth, 'Content-Type': 'application/json', @@ -203,9 +203,13 @@ def process_audit_collect(request): if not user_main.OpenID: return Response({'code': 10, 'msg': '用户未绑定微信,无法收款'}) - wx_cfg = settings.WECHAT_PAY_V3_CONFIG + from jituan.services.club_user import get_user_club_id + from jituan.services.wechat_pay import get_wechat_v3_config + + payout_club_id = get_user_club_id(user_main) + wx_cfg = get_wechat_v3_config(payout_club_id) if not wx_cfg.get('APPID') or not wx_cfg.get('MCHID') or not wx_cfg.get('TRANSFER_SCENE_ID'): - logger.error('微信打款配置不完整 APPID=%s MCHID=%s', wx_cfg.get('APPID'), wx_cfg.get('MCHID')) + logger.error('微信打款配置不完整 club=%s APPID=%s MCHID=%s', payout_club_id, wx_cfg.get('APPID'), wx_cfg.get('MCHID')) return Response({'code': 11, 'msg': '系统配置异常,请联系客服'}) ctx, err = resolve_collect_context( @@ -295,8 +299,10 @@ def process_audit_collect(request): quota_reserved = True tixian_id = generate_tixian_id() + from jituan.services.club_user import get_user_club_id TixianAutoRecord.query.create( tixian_id=tixian_id, + club_id=get_user_club_id(user_main), yonghuid=yonghuid, leixing=leixing, jine=jine, @@ -314,7 +320,7 @@ def process_audit_collect(request): ) try: - resp, wx_result = _call_wechat_transfer(body, wx_cfg) + resp, wx_result = _call_wechat_transfer(body, wx_cfg, payout_club_id) if resp.status_code == 200 and wx_result.get('state') == 'WAIT_USER_CONFIRM': package_info = wx_result.get('package_info') diff --git a/users/views.py b/users/views.py index 7ee6505..1cb8a1b 100644 --- a/users/views.py +++ b/users/views.py @@ -43,8 +43,6 @@ from gvsdsdk.fluent import db, func, FQ from utils.oss_utils import validate_image, upload_to_oss, delete_from_oss from utils.fadan_utils import check_fadan_qiangdan_eligible -from utils.wechat_v3 import verify_wechat_sign, decrypt_callback_ciphertext - from utils.invitationcode_utils import CreateInvitationCode, VerifyInvitationCode from users.fadan_fenhong_utils import process_fadan_fenhong @@ -83,7 +81,7 @@ from orders.models import ( from products.models import ( ShangpinLeixing, Huiyuan, Huiyuangoumai, Gsfenhong, Czjilu ) -from config.models import Qunpeizhi, Szjilu +from config.models import Qunpeizhi from backend.models import MerchantDailyStats from rank.models import ( KaohePayTemp, Chenghao, ShenheJilu, @@ -1327,6 +1325,15 @@ class DashouZhuceView(APIView): 'data': None }, status=status.HTTP_404_NOT_FOUND) + from jituan.services.invite_guard import assert_invite_same_club + ok, msg = assert_invite_same_club(request, guanshi_profile.user, current_user) + if not ok: + return Response({ + 'code': 403, + 'message': msg, + 'data': None + }, status=status.HTTP_403_FORBIDDEN) + # 4. 验证管事状态 # 5. 核心:在数据库事务中创建用户的所有身份 @@ -1590,25 +1597,20 @@ class TixianXinxiHuoquView(APIView): # 从request.user获取当前用户 user_main = request.user - # 🔥【修改开始 - 使用Decimal,字段名是Rate】 - # 查询打手提现费率 (Platform='5') - dashou_rate_obj = CommissionRate.query.filter(Platform='5').first() - dashou_rate = decimal.Decimal(str(dashou_rate_obj.Rate)) if dashou_rate_obj else decimal.Decimal('0.00') + from jituan.services.club_user import get_user_club_id + from jituan.services.withdraw_config import get_tixian_feilv - # 查询管事提现费率 (Platform='6') - guanshi_rate_obj = CommissionRate.query.filter(Platform='6').first() - guanshi_rate = decimal.Decimal(str(guanshi_rate_obj.Rate)) if guanshi_rate_obj else decimal.Decimal('0.00') - # 🔥【修改结束】 + club_id = get_user_club_id(user_main) + dashou_rate = get_tixian_feilv(club_id, 1) + guanshi_rate = get_tixian_feilv(club_id, 2) # 构建返回数据 response_data = { 'txdianhua': user_main.Phone if user_main.Phone else '', 'txtupian': user_main.PaymentQRCode if user_main.PaymentQRCode else '', 'txzh': user_main.PaymentAccount if user_main.PaymentAccount else '', - # 🔥【修改开始】 - 'dashou_rate': dashou_rate, # 打手提现费率 - 'guanshi_rate': guanshi_rate, # 管事提现费率 - # 🔥【修改结束】 + 'dashou_rate': dashou_rate, + 'guanshi_rate': guanshi_rate, } # 返回成功响应 @@ -1850,7 +1852,10 @@ class TixianShenqingView(APIView): return Response({'code': 5, 'msg': '无效的收款方式,只能是1(微信)或2(支付宝)'}, status=status.HTTP_400_BAD_REQUEST) - # 2. 获取对应费率及手续费 + from jituan.services.club_user import get_user_club_id + from jituan.services.withdraw_config import get_tixian_feilv + + club_id = get_user_club_id(user_main) current_rate = decimal.Decimal('0.00') rate_key = { 1: '5', # 打手佣金 @@ -1862,10 +1867,9 @@ class TixianShenqingView(APIView): }.get(leixing) if rate_key: - rate_obj = CommissionRate.query.filter(Platform=rate_key).first() - if rate_obj: - current_rate = decimal.Decimal(str(rate_obj.Rate)) - else: + try: + current_rate = get_tixian_feilv(club_id, leixing) + except ValueError: return Response({'code': 41, 'msg': f'提现费率未配置(类型{leixing})'}, status=status.HTTP_400_BAD_REQUEST) @@ -1956,7 +1960,9 @@ class TixianShenqingView(APIView): phone = user_main.Phone or '' zhifu = user_main.PaymentQRCode or '' skzhanghao = user_main.PaymentAccount or '' + from jituan.services.club_user import get_user_club_id tixian_record = Tixianjilu.query.create( + club_id=get_user_club_id(user_main), yonghuid=yonghuid, avatar=avatar, phone=phone, @@ -2482,43 +2488,10 @@ class AdminFinancialDataView(APIView): status=status.HTTP_401_UNAUTHORIZED ) - # 6. 查询收支记录表(Szjilu) - # 使用高效的ORM查询,获取最新的一条记录 - # 注意:根据你的业务需求,这里获取最新的一条记录 - # 如果需要汇总多条记录,可以使用aggregate进行聚合查询 - - # 方法1:获取最新的一条记录(假设表中只有一条汇总记录) - shouzhijilu = Szjilu.query.order_by('-CreateTime').first() - - # 方法2:如果表中可能有多条记录,需要汇总,可以使用以下方式: - # from django.db.models Sum - # zonghe = Szjilu.query.aggregate( - # zongliushui=Sum('TotalFlow'), - # zongshouyi=Sum('TotalIncome'), - # zongzhichu=Sum('TotalExpense'), - # DailyFlow=Sum('DailyFlow'), - # DailyExpense=Sum('DailyExpense') - # ) - - # 准备返回数据 - if shouzhijilu: - # 有记录的情况 - huizongshuju = { - 'zongliushui': float(shouzhijilu.TotalFlow or 0), - 'zongshouyi': float(shouzhijilu.TotalIncome or 0), - 'zongzhichu': float(shouzhijilu.TotalExpense or 0), - 'jrls': float(shouzhijilu.DailyFlow or 0), - 'jrzc': float(shouzhijilu.DailyExpense or 0) - } - else: - # 没有记录的情况,返回默认值 - huizongshuju = { - 'zongliushui': 0.00, - 'zongshouyi': 0.00, - 'zongzhichu': 0.00, - 'jrls': 0.00, - 'jrzc': 0.00 - } + # 6. 查询收支记录表(按俱乐部 xq,旧管理员端默认) + from jituan.constants import CLUB_ID_DEFAULT + from jituan.services.szjilu_accounting import get_szjilu_snapshot + huizongshuju = get_szjilu_snapshot(CLUB_ID_DEFAULT) # 7. 返回成功响应 return Response({ @@ -4347,11 +4320,13 @@ class AdcjxgView(APIView): # 🔥 修改:新购买时只能增加天数 if days > 0: daoqi_time = timezone.now() + timedelta(days=days) + from jituan.services.club_user import get_user_club_id new_record = Huiyuangoumai.query.create( huiyuan_id=huiyuan_id, yonghu_id=uid, jieshao=huiyuan.jieshao, - daoqi_time=daoqi_time + daoqi_time=daoqi_time, + club_id=get_user_club_id(User.query.filter(UserUID=uid).first()), ) # 记录会员购买 @@ -4638,29 +4613,14 @@ class AdtixianqkView(APIView): # 🟢 新增:如果同意提现(result=2),更新收支记录表 if result == 2: try: - # 查询收支记录表,id=1的记录 - szjilu_record = Szjilu.query.get(id=1) - - # 获取提现金额 - tixian_jine = withdrawal.jine # 假设提现记录模型中有jine字段 - - update_daily_payout(tixian_jine) - - # 更新收支记录 - szjilu_record.TotalIncome -= tixian_jine # 总收益减去提现金额 - szjilu_record.TotalExpense += tixian_jine # 总支出加上提现金额 - szjilu_record.DailyExpense += tixian_jine # 今日支出加上提现金额 - szjilu_record.UpdateTime = timezone.now() - szjilu_record.save() - - except Szjilu.DoesNotExist: - # 如果收支记录表不存在id=1的记录,创建一个 - szjilu_record = Szjilu.query.create( - id=1, - TotalIncome=-tixian_jine if result == 2 else 0, - TotalExpense=tixian_jine if result == 2 else 0, - DailyExpense=tixian_jine if result == 2 else 0 + from jituan.services.szjilu_accounting import apply_szjilu_expense + tixian_jine = withdrawal.jine + from jituan.services.club_user import get_user_club_id + payout_club = get_user_club_id( + User.query.filter(UserUID=withdrawal.yonghuid).first() ) + update_daily_payout(tixian_jine, payout_club) + apply_szjilu_expense(tixian_jine, payout_club) except Exception as e: # 如果更新收支记录失败,抛出异常,事务会回滚 raise Exception(f"更新收支记录失败: {str(e)}") @@ -5372,6 +5332,22 @@ class WechatLoginAndDashouRegisterView(APIView): # 这里我们继续执行,但只返回登录信息 return self.fanhuiZhihouDengluXinxi(user_main, yaoqingma_wuxiao=True) + from jituan.services.invite_guard import assert_invite_same_club + from jituan.services.club_user import ensure_user_club_id + from jituan.services.club_context import resolve_club_id_from_request + + ok, invite_msg = assert_invite_same_club( + request, guanshi_profile.user, user_main, + ) + if not ok: + return Response({ + 'code': 403, + 'msg': invite_msg, + 'data': None + }, status=status.HTTP_403_FORBIDDEN) + + ensure_user_club_id(user_main, resolve_club_id_from_request(request)) + # 验证管事状态 # 4.3 检查用户是否已是打手 @@ -7100,8 +7076,12 @@ class KefuGetOrderListView(APIView): if clkf: q_conditions &= Q(AssignedCS__icontains=clkf) + from jituan.services.club_context import orders_for_request + # ---------- 统计(仅按发单平台,非跨平台) ---------- - stats = Order.query.filter(Platform=1).aggregate( + club_orders = orders_for_request(request) + platform_orders = club_orders.filter(Platform=1) + stats = platform_orders.aggregate( total_orders=Count('id'), completed_orders=Count('id', filter=Q(Status=3)), refund_orders=Count('id', filter=Q(Status=5)), @@ -7110,20 +7090,20 @@ class KefuGetOrderListView(APIView): # ---------- 状态计数(所有状态都返回,非跨平台) ---------- status_counts = { - 'all': Order.query.filter(Platform=1).count(), - '1,7': Order.query.filter(Platform=1, Status__in=[1,7]).count(), - '2': Order.query.filter(Platform=1, Status=2).count(), - '8': Order.query.filter(Platform=1, Status=8).count(), - '3': Order.query.filter(Platform=1, Status=3).count(), - '4': Order.query.filter(Platform=1, Status=4).count(), - '5': Order.query.filter(Platform=1, Status=5).count(), - '6': Order.query.filter(Platform=1, Status=6).count(), + 'all': platform_orders.count(), + '1,7': platform_orders.filter(Status__in=[1,7]).count(), + '2': platform_orders.filter(Status=2).count(), + '8': platform_orders.filter(Status=8).count(), + '3': platform_orders.filter(Status=3).count(), + '4': platform_orders.filter(Status=4).count(), + '5': platform_orders.filter(Status=5).count(), + '6': platform_orders.filter(Status=6).count(), } # ---------- 分页查询 ---------- - total_count = Order.query.filter(q_conditions).count() + total_count = club_orders.filter(q_conditions).count() - orders_qs = Order.query.filter(q_conditions).select_related( + orders_qs = club_orders.filter(q_conditions).select_related( 'pingtai_kuozhan' ).only( 'OrderID', 'Description', 'Status', 'ProductTypeID', 'Platform', @@ -7158,6 +7138,8 @@ class KefuGetOrderListView(APIView): 'youxi_nicheng': youxi_nicheng }) + from jituan.services.club_user_access import list_response_meta + response_data = { 'stats': { 'totalOrders': stats['total_orders'] or 0, @@ -7169,7 +7151,8 @@ class KefuGetOrderListView(APIView): 'list': result_list, 'total': total_count, 'page': page, - 'pageSize': page_size + 'pageSize': page_size, + **list_response_meta(request), } logger.info(f"平台订单接口总耗时: {time.time()-start_total:.3f}s") @@ -7269,8 +7252,12 @@ class KefuGetShangjiaOrderListView(APIView): ShopProfile__nicheng__icontains=shangjia_nicheng ).values_list('UserUID', flat=True)) + from jituan.services.club_context import orders_for_request + # ---------- 统计(仅按发单平台,非跨平台) ---------- - stats = Order.query.filter(Platform=2).aggregate( + club_orders = orders_for_request(request) + merchant_orders = club_orders.filter(Platform=2) + stats = merchant_orders.aggregate( total_orders=Count('id'), completed_orders=Count('id', filter=Q(Status=3)), refund_orders=Count('id', filter=Q(Status=5)), @@ -7279,20 +7266,20 @@ class KefuGetShangjiaOrderListView(APIView): # ---------- 状态计数(所有状态都返回,非跨平台) ---------- status_counts = { - 'all': Order.query.filter(Platform=2).count(), - '1,7': Order.query.filter(Platform=2, Status__in=[1,7]).count(), - '2': Order.query.filter(Platform=2, Status=2).count(), - '8': Order.query.filter(Platform=2, Status=8).count(), - '3': Order.query.filter(Platform=2, Status=3).count(), - '4': Order.query.filter(Platform=2, Status=4).count(), - '5': Order.query.filter(Platform=2, Status=5).count(), - '6': Order.query.filter(Platform=2, Status=6).count(), + 'all': merchant_orders.count(), + '1,7': merchant_orders.filter(Status__in=[1,7]).count(), + '2': merchant_orders.filter(Status=2).count(), + '8': merchant_orders.filter(Status=8).count(), + '3': merchant_orders.filter(Status=3).count(), + '4': merchant_orders.filter(Status=4).count(), + '5': merchant_orders.filter(Status=5).count(), + '6': merchant_orders.filter(Status=6).count(), } # ---------- 分页查询 ---------- - total_count = Order.query.filter(q_conditions).count() + total_count = club_orders.filter(q_conditions).count() - orders_qs = Order.query.filter(q_conditions).select_related( + orders_qs = club_orders.filter(q_conditions).select_related( 'shangjia_kuozhan' ).only( 'OrderID', 'Description', 'Status', 'ProductTypeID', 'Platform', @@ -7327,6 +7314,8 @@ class KefuGetShangjiaOrderListView(APIView): 'youxi_nicheng': youxi_nicheng }) + from jituan.services.club_user_access import list_response_meta + response_data = { 'stats': { 'totalOrders': stats['total_orders'] or 0, @@ -7338,7 +7327,8 @@ class KefuGetShangjiaOrderListView(APIView): 'list': result_list, 'total': total_count, 'page': page, - 'pageSize': page_size + 'pageSize': page_size, + **list_response_meta(request), } logger.info(f"商家订单接口总耗时: {time.time()-start_total:.3f}s") @@ -7708,7 +7698,7 @@ class KefuPlatformRefundView(APIView): # 10. 更新退款记录表(假设存在 RefundRecord 模型,字段需根据实际调整) #更新支出记录 - update_daily_payout(order.Amount) + update_daily_payout(order.Amount, getattr(order, 'ClubID', None)) try: @@ -9033,14 +9023,18 @@ class KefuPunishmentListView(APIView): return Response({'code': 403, 'msg': '您无权查看平台订单列表'}) # 3. 获取统计信息(不分页) + from jituan.services.club_penalty import filter_penalty_record_qs + from jituan.services.club_user_access import list_response_meta + + base_qs = filter_penalty_record_qs(PenaltyRecord.query.all(), request) stats = { - 'zongshu': PenaltyRecord.query.count(), - 'daichuli': PenaltyRecord.query.filter(ApplyStatus__in=[0, 3]).count(), - 'yichuli': PenaltyRecord.query.filter(ApplyStatus__in=[1, 2]).count(), + 'zongshu': base_qs.count(), + 'daichuli': base_qs.filter(ApplyStatus__in=[0, 3]).count(), + 'yichuli': base_qs.filter(ApplyStatus__in=[1, 2]).count(), } # 4. 构建查询条件 - queryset = PenaltyRecord.query.all() + queryset = base_qs if status_list and isinstance(status_list, list): queryset = queryset.filter(ApplyStatus__in=status_list) @@ -9075,7 +9069,8 @@ class KefuPunishmentListView(APIView): 'data': { 'list': list_data, 'total': total, - 'stats': stats + 'stats': stats, + **list_response_meta(request), } }) @@ -9696,12 +9691,14 @@ class KefuWithdrawListView(APIView): ) return False - def _list_queryset(self, q, shenhe_field_ok): + def _list_queryset(self, q, shenhe_field_ok, request): """字段未迁移时 only 旧字段,避免 SELECT 不存在的列导致 500""" fields = _TIXIAN_LIST_BASE_FIELDS + ( _TIXIAN_LIST_AUDIT_FIELDS if shenhe_field_ok else () ) - return Tixianjilu.query.filter(q).only(*fields) + from jituan.services.tixian_club import filter_tixianjilu_by_request + qs = Tixianjilu.query.filter(q).only(*fields) + return filter_tixianjilu_by_request(qs, request) def _resolve_row_dakuan_mode(self, item, audit_mode_map, shenhe_field_ok): """无关联审核或审核表未标记自动 → 默认手动(1)""" @@ -9747,7 +9744,7 @@ class KefuWithdrawListView(APIView): if kefu is None: return permissions if '005bb' not in permissions: - return Response({'code': 403, 'msg': '您无权查看平台订单列表'}) + return Response({'code': 403, 'msg': '您无权查看提现审核列表'}) q = Q() @@ -9824,11 +9821,11 @@ class KefuWithdrawListView(APIView): processed_statuses = [2, 3, 4, 5, 6] if effective_dakuan == 2 else [2, 3] - base_qs = self._list_queryset(q, shenhe_field_ok) + base_qs = self._list_queryset(q, shenhe_field_ok, request) total_count = base_qs.count() - awaiting_count = self._list_queryset(q & Q(zhuangtai=1), shenhe_field_ok).count() + awaiting_count = self._list_queryset(q & Q(zhuangtai=1), shenhe_field_ok, request).count() processed_count = self._list_queryset( - q & Q(zhuangtai__in=processed_statuses), shenhe_field_ok, + q & Q(zhuangtai__in=processed_statuses), shenhe_field_ok, request, ).count() agg = base_qs.aggregate(total=Sum('jine')) total_amount = agg.get('total') or 0 @@ -9888,12 +9885,15 @@ class KefuWithdrawListView(APIView): 'UpdateTime': item.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if item.UpdateTime else '', }) + from jituan.services.club_user_access import list_response_meta + return Response({ 'code': 0, 'data': { 'list': list_data, 'total': total, 'stats': stats, + **list_response_meta(request), }, }) @@ -9971,6 +9971,11 @@ class KefuWithdrawDetailView(APIView): except Tixianjilu.DoesNotExist: return Response({'code': 404, 'msg': '提现记录不存在'}, status=status.HTTP_404_NOT_FOUND) + from jituan.services.tixian_club import forbid_if_tixian_out_of_scope + deny = forbid_if_tixian_out_of_scope(request, record) + if deny: + return deny + # 查询用户主表 try: user = User.query.get(UserUID=record.yonghuid) @@ -10113,7 +10118,7 @@ class KefuWithdrawActionView(APIView): return None, Response({'code': 400, 'msg': '参数格式错误'}, status=status.HTTP_400_BAD_REQUEST) return [item], None - def _process_auto_item(self, item, action, reason, kefu_user): + def _process_auto_item(self, request, item, action, reason, kefu_user): """自动打款单条处理(须在 transaction.atomic 内调用)""" tixian_id = item['tixian_id'] req_yonghuid = item['yonghuid'] @@ -10127,6 +10132,11 @@ class KefuWithdrawActionView(APIView): except Tixianjilu.DoesNotExist: raise ValueError(f'提现记录{tixian_id}不存在') + from jituan.services.tixian_club import forbid_if_tixian_out_of_scope + deny = forbid_if_tixian_out_of_scope(request, tixian) + if deny: + raise ValueError(deny.data.get('msg', '无权限处理该提现记录')) + if tixian.yonghuid != req_yonghuid: raise ValueError(f'提现记录{tixian_id}:用户ID不匹配') if tixian.leixing != req_leixing: @@ -10181,7 +10191,7 @@ class KefuWithdrawActionView(APIView): tixian.bhliyou = reason tixian.save(update_fields=['shenheid', 'bhliyou', 'UpdateTime']) - def _process_manual_item(self, item, action, reason, phone): + def _process_manual_item(self, request, item, action, reason, phone): """手动打款单条处理(原逻辑,须在 transaction.atomic 内调用)""" tixian_id = item['tixian_id'] try: @@ -10189,6 +10199,11 @@ class KefuWithdrawActionView(APIView): except Tixianjilu.DoesNotExist: raise ValueError(f'提现记录{tixian_id}不存在') + from jituan.services.tixian_club import forbid_if_tixian_out_of_scope + deny = forbid_if_tixian_out_of_scope(request, tixian) + if deny: + raise ValueError(deny.data.get('msg', '无权限处理该提现记录')) + req_yonghuid = item.get('yonghuid') req_leixing = item.get('leixing') if req_yonghuid and tixian.yonghuid != req_yonghuid: @@ -10206,16 +10221,14 @@ class KefuWithdrawActionView(APIView): except User.DoesNotExist: raise ValueError(f'用户{tixian.yonghuid}不存在') + from jituan.services.club_user import get_user_club_id + if action == 1: tixian.zhuangtai = 2 try: - update_daily_payout(tixian.jine) - szjilu, _ = Szjilu.query.get_or_create(id=1) - szjilu.TotalIncome -= tixian.jine - szjilu.TotalExpense += tixian.jine - szjilu.DailyExpense += tixian.jine - szjilu.UpdateTime = timezone.now() - szjilu.save() + from jituan.services.szjilu_accounting import apply_szjilu_expense + update_daily_payout(tixian.jine, get_user_club_id(user)) + apply_szjilu_expense(tixian.jine, get_user_club_id(user)) except Exception as e: logger.error(f'更新收支记录失败: {e}') else: @@ -10265,15 +10278,20 @@ class KefuWithdrawActionView(APIView): except Tixianjilu.DoesNotExist: raise ValueError(f'提现记录{tixian_id}不存在') + from jituan.services.tixian_club import forbid_if_tixian_out_of_scope + deny = forbid_if_tixian_out_of_scope(request, tixian_probe) + if deny: + raise ValueError(deny.data.get('msg', '无权限处理该提现记录')) + # 有关联审核记录(shenhe_jilu_id)即为 zddksh 自动打款,不依赖 dakuan_mode 字段 is_auto = bool(getattr(tixian_probe, 'shenhe_jilu_id', None)) if is_auto: - self._process_auto_item(item, action, reason, kefu_user) + self._process_auto_item(request, item, action, reason, kefu_user) else: if is_batch: raise ValueError(f'提现记录{tixian_id}:批量操作仅支持自动打款记录') - self._process_manual_item(item, action, reason, phone) + self._process_manual_item(request, item, action, reason, phone) except ValueError as e: return Response({'code': 400, 'msg': str(e)}, status=status.HTTP_400_BAD_REQUEST) @@ -10943,6 +10961,12 @@ class GuanshiRegisterView(APIView): return Response({'code': 403, 'msg': '该邀请码对应的组长账号已被禁用', 'data': None}, status=status.HTTP_403_FORBIDDEN) + from jituan.services.invite_guard import assert_invite_same_club + ok, invite_msg = assert_invite_same_club(request, zuzhang.user, current_user) + if not ok: + return Response({'code': 403, 'msg': invite_msg, 'data': None}, + status=status.HTTP_403_FORBIDDEN) + zuzhang_yonghuid = zuzhang.user.UserUID # 4. 事务内创建管事实体和打手(如果需要) @@ -11197,6 +11221,10 @@ class ZuzhangZhuceView(APIView): if invite_code != self.ACTIVATION_CODE: return Response({'code': 400, 'msg': '邀请码无效'}, status=400) + from jituan.services.club_user import ensure_user_club_id + from jituan.services.club_context import resolve_club_id_from_request + ensure_user_club_id(user, resolve_club_id_from_request(request)) + # 生成唯一组长邀请码:时间戳 + 用户ID后6位 + 随机字符 yaoqingma = self._generate_unique_yaoqingma(user.UserUID) @@ -11707,13 +11735,15 @@ class FaKuanPayView(APIView): random_num = random.randint(0, 99) dingdanid = f"FK{timestamp_ms:011d}{timestamp_ns:06d}{random_num:02d}" + from jituan.services.club_write import resolve_club_id_for_write chongzhi_jilu = Czjilu.query.create( dingdan_id=dingdanid, zhuangtai=9, yonghuid=request.user.UserUID, jine=jine, leixing=4, - shuoming=settings.FAKUAN_PAY_DESCRIPTION + shuoming=settings.FAKUAN_PAY_DESCRIPTION, + club_id=resolve_club_id_for_write(request, request.user), ) try: @@ -11746,9 +11776,12 @@ class FaKuanPayView(APIView): }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) def generate_wechat_pay_params(self, dingdanid, jine, openid, pay_type): - APPID = settings.WEIXIN_APPID - MCHID = settings.WEIXIN_MCHID - KEY = settings.WEIXIN_SHANGHUMIYAO + from jituan.services.club_write import resolve_club_id_for_write + from jituan.services.wechat_pay import get_wechat_v2_config + pay_cfg = get_wechat_v2_config(resolve_club_id_for_write(self.request, request.user)) + APPID = pay_cfg['appid'] + MCHID = pay_cfg['mch_id'] + KEY = pay_cfg['key'] if pay_type == 'fakuan': PAY_DESCRIPTION = settings.FAKUAN_PAY_DESCRIPTION @@ -11884,7 +11917,9 @@ class FaKuanHuitiaoView(View): return self.wechat_response('FAIL', '支付失败') # 3. 验证签名(确保是微信发来的) - if not self.verify_signature(request_body): + from jituan.services.wechat_pay import club_id_from_czjilu, verify_wechat_v2_signature + notify_club = club_id_from_czjilu(out_trade_no) + if not verify_wechat_v2_signature(request_body, notify_club): logger.error(f"签名验证失败: {out_trade_no}") return self.wechat_response('FAIL', '签名验证失败') @@ -11944,26 +11979,9 @@ class FaKuanHuitiaoView(View): # 获取订单金额(确保是Decimal类型) jine_decimal = Decimal(str(order.jine)) if not isinstance(order.jine, Decimal) else order.jine - update_daily_income(jine_decimal) - - # 查询或创建ID=1的收支记录 - szjilu, created = Szjilu.query.get_or_create( - id=1, - defaults={ - 'TotalIncome': Decimal('0.00'), - 'TotalFlow': Decimal('0.00'), - 'TotalExpense': Decimal('0.00'), - 'DailyExpense': Decimal('0.00'), - 'DailyFlow': Decimal('0.00') - } - ) - - # 更新三个字段 - szjilu.TotalIncome += jine_decimal # 总收益 - szjilu.TotalFlow += jine_decimal # 总流水 - szjilu.DailyFlow += jine_decimal # 今日流水 - - szjilu.save() + update_daily_income(jine_decimal, getattr(order, 'club_id', None)) + from jituan.services.szjilu_accounting import apply_szjilu_income + apply_szjilu_income(jine_decimal, getattr(order, 'club_id', None)) logger.info(f"收支记录更新成功: 订单{order.dingdan_id}, 金额{jine_decimal}元") @@ -12181,13 +12199,15 @@ class KaohePayView(APIView): logger.info(f"生成订单ID: {dingdanid}") # 8. 创建支付订单(Czjilu) + from jituan.services.club_write import resolve_club_id_for_write order = Czjilu.query.create( dingdan_id=dingdanid, zhuangtai=9, # 未支付 yonghuid=user.UserUID, jine=float(fee), leixing=5, # 5-考核支付 - shuoming=settings.KAOHE_PAY_DESCRIPTION + shuoming=settings.KAOHE_PAY_DESCRIPTION, + club_id=resolve_club_id_for_write(request, user), ) logger.info(f"创建支付订单成功: {dingdanid}") @@ -12249,9 +12269,12 @@ class KaohePayView(APIView): def generate_wechat_pay_params(self, dingdanid, jine, openid, attach, pay_type): """生成微信JSAPI支付参数""" - APPID = settings.WEIXIN_APPID - MCHID = settings.WEIXIN_MCHID - KEY = settings.WEIXIN_SHANGHUMIYAO + from jituan.services.club_write import resolve_club_id_for_write + from jituan.services.wechat_pay import get_wechat_v2_config + pay_cfg = get_wechat_v2_config(resolve_club_id_for_write(self.request, request.user)) + APPID = pay_cfg['appid'] + MCHID = pay_cfg['mch_id'] + KEY = pay_cfg['key'] if pay_type == 'kaohe': PAY_DESCRIPTION = settings.KAOHE_PAY_DESCRIPTION @@ -12441,7 +12464,9 @@ class KaohePayCallbackView(View): return self.wechat_response('FAIL', '支付失败') # 验证签名 - if not self.verify_signature(request_body): + from jituan.services.wechat_pay import club_id_from_czjilu, verify_wechat_v2_signature + notify_club = club_id_from_czjilu(out_trade_no) + if not verify_wechat_v2_signature(request_body, notify_club): logger.error(f"签名验证失败,订单号: {out_trade_no}") return self.wechat_response('FAIL', '签名验证失败') logger.info(f"签名验证成功,订单号: {out_trade_no}") @@ -12513,24 +12538,11 @@ class KaohePayCallbackView(View): # 更新收支记录 try: jine_decimal = Decimal(str(order.jine)) - update_daily_income(jine_decimal) + update_daily_income(jine_decimal, getattr(order, 'club_id', None)) logger.info(f"更新每日收入成功: {jine_decimal}元") - - szjilu, created = Szjilu.query.get_or_create( - id=1, - defaults={ - 'TotalIncome': Decimal('0.00'), - 'TotalFlow': Decimal('0.00'), - 'TotalExpense': Decimal('0.00'), - 'DailyExpense': Decimal('0.00'), - 'DailyFlow': Decimal('0.00'), - } - ) - szjilu.TotalIncome += jine_decimal - szjilu.TotalFlow += jine_decimal - szjilu.DailyFlow += jine_decimal - szjilu.save() - logger.info(f"更新收支记录成功: zongsy={szjilu.TotalIncome}") + from jituan.services.szjilu_accounting import apply_szjilu_income + apply_szjilu_income(jine_decimal, getattr(order, 'club_id', None)) + logger.info(f"更新收支记录成功") except Exception as e: logger.error(f"更新收支记录失败: {str(e)}", exc_info=True) @@ -12709,30 +12721,11 @@ class TixianAssetView(APIView): except ObjectDoesNotExist: pass - # ------------------- 费率查询(从 CommissionRate 获取) ------------------- - # 打手提现费率 (类型 '5') - lilu_obj = CommissionRate.query.filter(Platform='5').first() - data['dashou_rate'] = float(lilu_obj.Rate) if lilu_obj and lilu_obj.Rate is not None else 0.00 + from jituan.services.club_user import get_user_club_id + from jituan.services.withdraw_config import build_tixian_asset_rates - # 打手保证金提现费率 (类型 '11') - lilu_obj = CommissionRate.query.filter(Platform='11').first() - data['dashou_yajin_rate'] = float(lilu_obj.Rate) if lilu_obj and lilu_obj.Rate is not None else 0.00 - - # 商家提现费率 (类型 '10') - lilu_obj = CommissionRate.query.filter(Platform='10').first() - data['shangjia_rate'] = float(lilu_obj.Rate) if lilu_obj and lilu_obj.Rate is not None else 0.00 - - # 管事提现费率 (类型 '6') - lilu_obj = CommissionRate.query.filter(Platform='6').first() - data['guanshi_rate'] = float(lilu_obj.Rate) if lilu_obj and lilu_obj.Rate is not None else 0.00 - - # 组长提现费率 (类型 '8') - lilu_obj = CommissionRate.query.filter(Platform='8').first() - data['zuzhang_rate'] = float(lilu_obj.Rate) if lilu_obj and lilu_obj.Rate is not None else 0.00 - - # 审核官(考核官)提现费率 (类型 '9') - lilu_obj = CommissionRate.query.filter(Platform='9').first() - data['kaoheguan_rate'] = float(lilu_obj.Rate) if lilu_obj and lilu_obj.Rate is not None else 0.00 + club_id = get_user_club_id(user) + data.update(build_tixian_asset_rates(club_id)) return Response({ 'code': 200, @@ -12861,8 +12854,10 @@ class TixianCallbackV3View(APIView): headers = request.headers body = request.body.decode('utf-8') - # 1. 验证签名 - if not verify_wechat_sign(headers, body): + # 1. 验证签名(多商户依次验签) + from utils.wechat_v3 import resolve_callback_club_id, decrypt_callback_ciphertext + callback_club_id = resolve_callback_club_id(headers, body) + if not callback_club_id: return Response({'code': 'FAIL', 'message': '签名验证失败'}, status=status.HTTP_401_UNAUTHORIZED) # 2. 解密数据 @@ -12878,7 +12873,7 @@ class TixianCallbackV3View(APIView): if not nonce or not ciphertext: return Response({'code': 'FAIL', 'message': '解密字段缺失'}, status=status.HTTP_400_BAD_REQUEST) - plaintext = decrypt_callback_ciphertext(associated_data, nonce, ciphertext) + plaintext = decrypt_callback_ciphertext(associated_data, nonce, ciphertext, club_id=callback_club_id) callback_data = json.loads(plaintext) except Exception as e: logger.error(f"回调解密失败: {str(e)}", exc_info=True) diff --git a/utils/wechat_v3.py b/utils/wechat_v3.py index 0920856..782b737 100644 --- a/utils/wechat_v3.py +++ b/utils/wechat_v3.py @@ -3,6 +3,7 @@ import time import hashlib import base64 import json +import os from urllib.parse import urlparse from cryptography.hazmat.primitives import serialization, hashes @@ -11,29 +12,33 @@ from cryptography.hazmat.backends import default_backend from Crypto.Cipher import AES from django.conf import settings -import os -def load_private_key(): - """加载私钥(支持 PKCS#1 和 PKCS#8)""" - key_path = settings.WECHAT_PAY_V3_CONFIG['PRIVATE_KEY_PATH'] +def _get_v3_config(club_id=None): + from jituan.services.wechat_pay import get_wechat_v3_config + return get_wechat_v3_config(club_id) + + +def load_private_key(club_id=None): + """加载私钥(支持 PKCS#1 和 PKCS#8)。""" + cfg = _get_v3_config(club_id) + key_path = cfg['PRIVATE_KEY_PATH'] with open(key_path, 'rb') as f: key_data = f.read() - # cryptography 自动识别格式 - private_key = serialization.load_pem_private_key( + return serialization.load_pem_private_key( key_data, password=None, - backend=default_backend() + backend=default_backend(), ) - return private_key -def build_authorization(method, url, body): - """生成V3接口的Authorization头""" - private_key = load_private_key() - mchid = settings.WECHAT_PAY_V3_CONFIG['MCHID'] - serial_no = settings.WECHAT_PAY_V3_CONFIG['CERT_SERIAL_NO'] +def build_authorization(method, url, body, club_id=None): + """生成 V3 接口的 Authorization 头。""" + cfg = _get_v3_config(club_id) + private_key = load_private_key(club_id) + mchid = cfg['MCHID'] + serial_no = cfg['CERT_SERIAL_NO'] parsed = urlparse(url) path = parsed.path @@ -43,25 +48,25 @@ def build_authorization(method, url, body): timestamp = str(int(time.time())) nonce = hashlib.md5((timestamp + 'wechat').encode()).hexdigest()[:32] - # 构造签名串 message = '\n'.join([method, path, timestamp, nonce, body]) + '\n' - # 使用 cryptography 签名 signature = private_key.sign( message.encode('utf-8'), padding.PKCS1v15(), - hashes.SHA256() + hashes.SHA256(), ) signature_b64 = base64.b64encode(signature).decode('utf-8') - auth = f'WECHATPAY2-SHA256-RSA2048 mchid="{mchid}",nonce_str="{nonce}",timestamp="{timestamp}",serial_no="{serial_no}",signature="{signature_b64}"' - return auth + return ( + f'WECHATPAY2-SHA256-RSA2048 mchid="{mchid}",nonce_str="{nonce}",' + f'timestamp="{timestamp}",serial_no="{serial_no}",signature="{signature_b64}"' + ) -def verify_wechat_sign(headers, body): - """验证微信回调签名(需提前下载平台证书)""" - # 这部分使用 rsa 库验签,也可以改用 cryptography,但 rsa 已足够 +def verify_wechat_sign(headers, body, club_id=None): + """验证微信回调签名(需提前下载平台证书)。""" import rsa + serial = headers.get('Wechatpay-Serial') signature = headers.get('Wechatpay-Signature') timestamp = headers.get('Wechatpay-Timestamp') @@ -75,13 +80,14 @@ def verify_wechat_sign(headers, body): try: ts = int(headers.get('Wechatpay-Timestamp', '0')) - if abs(time.time() - ts) > 300: # 5分钟有效期 + if abs(time.time() - ts) > 300: return False except (ValueError, TypeError): return False + cfg = _get_v3_config(club_id) message = '\n'.join([timestamp, nonce, body]) + '\n' - cert_path = f"{settings.WECHAT_PAY_V3_CONFIG['PLATFORM_CERT_DIR']}/{serial}.pem" + cert_path = f"{cfg['PLATFORM_CERT_DIR']}/{serial}.pem" if not os.path.exists(cert_path): return False @@ -96,9 +102,27 @@ def verify_wechat_sign(headers, body): return False -def decrypt_callback_ciphertext(associated_data, nonce, ciphertext): - """解密回调数据(使用 AES-GCM)""" - api_v3_key = settings.WECHAT_PAY_V3_CONFIG['API_V3_KEY'].encode('utf-8') +def resolve_callback_club_id(headers, body): + """ + 多商户回调:依次用各俱乐部平台证书验签,返回匹配的 club_id。 + 验签失败返回 None。 + """ + from jituan.constants import CLUB_ID_DEFAULT + from jituan.models import Club + + if verify_wechat_sign(headers, body, club_id=CLUB_ID_DEFAULT): + return CLUB_ID_DEFAULT + + for club in Club.query.filter(status=1).exclude(club_id=CLUB_ID_DEFAULT): + if verify_wechat_sign(headers, body, club_id=club.club_id): + return club.club_id + return None + + +def decrypt_callback_ciphertext(associated_data, nonce, ciphertext, club_id=None): + """解密回调数据(使用 AES-GCM)。""" + cfg = _get_v3_config(club_id) + api_v3_key = cfg['API_V3_KEY'].encode('utf-8') nonce_bytes = nonce.encode('utf-8') ciphertext_bytes = base64.b64decode(ciphertext)