feat: 押金充值接入资金冻结池,接单按在押总额校验

This commit is contained in:
XingQue committed 2026-08-08 07:30:30 +08:00
1 parent 6c22a0b77e
commit fb2cfbcfc9
13 files changed
+619 -388

No files matched your search

+346 -348
View File
@@ -1,348 +1,346 @@
import io
import os
import re
import time
import random
import secrets
import hashlib
import threading
import urllib.parse
from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.db import transaction, connection
from django.db.models import Q, F, Max, Prefetch
from gvsdsdk.fluent import db, func, FQ
from django.core.cache import cache
from django.core.paginator import Paginator
from django.utils import timezone
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status, permissions
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.parsers import JSONParser, MultiPartParser, FormParser
from rest_framework.throttling import AnonRateThrottle, SimpleRateThrottle
from utils.oss_utils import upload_to_oss, delete_from_oss, validate_image
from utils.weixin_broadcast import WeixinBroadcastSender
from utils.weixin_token import get_weixin_mini_access_token, is_weixin_token_invalid
# from utils.ip_security import *
from utils.invitationcode_utils import CreateInvitationCode, VerifyInvitationCode
from backend.utils import update_shangjia_daily
from utils.chat_utils import subscribe_merchant_link_chat
from utils.pdd_order_validator import validate_pdd_order_id, validate_cn_mobile
from ..models import (
Gonggao, Lunbo, Tupianpeizhi, Qunpeizhi,
ShangjiaMoban, ShangjiaLianjie, PopupPage, PopupConfig, WithdrawConfig,
MiniappScriptScene, MiniappScriptAutoReply,
)
from users.models import (
AdminProfile, UserDashou, UserBoss,
UserShangjia, UserGuanshi, UserZuzhang
)
from users.business_models import User
from orders.models import CommissionRate, Order, MerchantOrderExt
from orders.notice_tasks import dingdan_guangbo
from rank.models import Chenghao, DingdanBiaoqian
from products.models import ShangpinLeixing, Huiyuangoumai
from ..serializers import PopupConfigSerializer
import traceback
import requests
import logging
logger = logging.getLogger(__name__)
class GetDynamicConfigView(APIView):
permission_classes = [AllowAny]
parser_classes = [JSONParser]
def post(self, request):
try:
from jituan.services.display_config import get_tupianpeizhi_url
from jituan.services.miniapp_assets import build_miniapp_assets_payload
cos_oss_url = getattr(settings, 'COS_DOMAIN', '')
if cos_oss_url and not cos_oss_url.endswith('/'):
cos_oss_url += '/'
cos_bucket = getattr(settings, 'COS_BUCKET', '')
cos_region = getattr(settings, 'COS_REGION', 'ap-shanghai')
goeasy_host = 'hangzhou.goeasy.io'
goeasy_appkey = getattr(settings, 'GOEASY_APPKEY', '')
kefu_link = getattr(settings, 'KEFU_LINK', '')
kefu_enterprise_id = getattr(settings, 'KEFU_ENTERPRISE_ID', '')
rule_img = get_tupianpeizhi_url(request, 1)
avatar_img = get_tupianpeizhi_url(request, 2)
morentouxiang = avatar_img or 'beijing/morentouxiang.jpg'
dashouguize = rule_img or 'a_long/dashouguize.jpg'
# 构造返回数据
data = {
"code": 0,
"data": {
"cos": {
"bucket": cos_bucket,
"region": cos_region,
"ossImageUrl": cos_oss_url,
"uploadPathPrefix": "order/"
},
"goEasy": {
"host": goeasy_host,
"appkey": goeasy_appkey
},
"otherConfig": {
"morentouxiang": morentouxiang,
"dashouguize": dashouguize
},
"kefu": {
"link": kefu_link,
"enterpriseId": kefu_enterprise_id
},
"miniappAssets": build_miniapp_assets_payload(request),
}
}
return Response(data)
except Exception as e:
logger.error(f"获取动态配置失败: {str(e)}", exc_info=True)
return Response({"code": 500, "msg": "服务器内部错误"}, status=500)
class HaibaoPeizhiView(APIView):
"""小程序推广海报背景配置(按俱乐部)。"""
permission_classes = [AllowAny]
parser_classes = [JSONParser]
def post(self, request):
try:
from jituan.services.display_config import _display_club_id
from jituan.services.miniapp_assets import get_poster_payload
club_id = _display_club_id(request)
poster = get_poster_payload(club_id)
return Response({
'code': 0,
'msg': 'success',
'data': {
'club_id': club_id,
'poster': poster,
},
})
except Exception as e:
logger.error('获取海报配置失败: %s', e, exc_info=True)
return Response({'code': 500, 'msg': '服务器内部错误'}, status=500)
class PopupConfigView(APIView):
"""
获取指定页面的弹窗配置
请求格式:POST { "pageKey": "dashouzhongxin" }
返回格式:{ "code": 0, "data": { "serverTime": "...", "popups": [...] } }
"""
authentication_classes = []
permission_classes = [AllowAny]
def post(self, request):
# 获取参数 pageKey
page_key = request.data.get('pageKey')
if not page_key:
return Response({
'code': 400,
'msg': '缺少参数 pageKey'
}, status=status.HTTP_400_BAD_REQUEST)
# 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(club_id=club_id, page_key=page_key, is_active=True)
except PopupPage.DoesNotExist:
# 该页面没有配置弹窗,返回空
return Response({
'code': 0,
'data': {
'serverTime': timezone.now().isoformat(),
'popups': []
}
})
# 4. 查询该页面下所有启用的弹窗(且在有效时间范围内)
# start/end 为空视为不限制(避免后台「配了却永远查不到」)
from django.db.models import Q
now = timezone.now()
popups = PopupConfig.query.filter(
page=popup_page,
is_active=True,
).filter(
Q(start_time__isnull=True) | Q(start_time__lte=now),
Q(end_time__isnull=True) | Q(end_time__gte=now),
).prefetch_related('images') # 预加载图片,避免N+1查询
# 按 sort_order 排序(model Meta 已定义)
# 5. 序列化
serializer = PopupConfigSerializer(popups, many=True)
# 6. 返回数据
return Response({
'code': 0,
'data': {
'serverTime': now.isoformat(),
'ignore_user_mute': popup_page.ignore_user_mute, # 新增
'popups': serializer.data
}
})
class GetWithdrawModeView(APIView):
"""
获取提现模式配置
前端请求 /peizhi/hqtxzsym
返回格式: { "code": 0, "data": { "mode": 1 or 2 } }
"""
permission_classes = [IsAuthenticated] # JWT 认证
def post(self, request, *args, **kwargs):
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,
'club_id': club_id,
}
})
class CheckPhoneAuthView(APIView):
"""
判断当前登录用户是否需要强制手机号认证
规则:
- 已认证过手机号(PhoneVerified=True) → 不需要
- 未认证时,满足以下任一条件 → 需要认证:
打手:有会员购买记录,或押金 > 0
商家:已是商家(存在商家扩展表)
管事:已是管事(存在管事扩展表)
组长:已是组长(存在组长扩展表)
老板:有下单记录(alldingdan > 0)
子客服:已是有效商家客服(merchant_staff_member 活跃)
"""
permission_classes = [IsAuthenticated]
@staticmethod
def _phone_auth_completed(user):
"""是否已完成手机号认证(兼容 PhoneVerified 未写入的老数据)。"""
if bool(getattr(user, 'PhoneVerified', False)):
return True
phone = (getattr(user, 'Phone', None) or '').strip()
return len(phone) >= 11
def post(self, request):
uid = getattr(request.user, 'UserUID', None)
user = request.user
if uid:
try:
user = User.query.get(UserUID=uid)
except User.DoesNotExist:
user = request.user
# 俱乐部关闭强制收集手机号 → 前端 ensurePhoneAuth 不再跳转认证页
try:
from jituan.services.club_context import resolve_effective_club_id
from jituan.services.force_phone_auth import is_club_force_phone_auth
club_id = resolve_effective_club_id(request, user)
if not is_club_force_phone_auth(club_id):
return Response({'code': 0, 'need_auth': False})
except Exception:
pass
if self._phone_auth_completed(user):
return Response({'code': 0, 'need_auth': False})
if self._dashou_meets_threshold(user):
return Response({'code': 0, 'need_auth': True})
if self._shangjia_meets_threshold(user):
return Response({'code': 0, 'need_auth': True})
if self._guanshi_meets_threshold(user):
return Response({'code': 0, 'need_auth': True})
if self._zuzhang_meets_threshold(user):
return Response({'code': 0, 'need_auth': True})
if self._boss_meets_threshold(user):
return Response({'code': 0, 'need_auth': True})
if self._staff_meets_threshold(user):
return Response({'code': 0, 'need_auth': True})
return Response({'code': 0, 'need_auth': False})
def _staff_meets_threshold(self, user):
"""子客服:已是有效商家客服"""
try:
from merchant_ops.services.authz import get_active_staff_member
return get_active_staff_member(user) is not None
except Exception:
return False
def _dashou_meets_threshold(self, user):
"""打手:有会员记录、押金 > 0、或有过接单/成交订单"""
try:
dashou = UserDashou.query.get(user=user)
except UserDashou.DoesNotExist:
return False
if dashou.yajin and dashou.yajin > 0:
return True
from jituan.services.club_context import resolve_effective_club_id
club_id = resolve_effective_club_id(self.request, user)
if Huiyuangoumai.query.filter(yonghu_id=user.UserUID, club_id=club_id).exists():
return True
if (dashou.jiedanzongliang or 0) > 0 or (dashou.chengjiaozongliang or 0) > 0:
return True
return Order.query.filter(PlayerID=user.UserUID).exists()
def _shangjia_meets_threshold(self, user):
"""商家:已是商家身份"""
return UserShangjia.query.filter(user=user).exists()
def _guanshi_meets_threshold(self, user):
"""管事:已是管事身份"""
return UserGuanshi.query.filter(user=user).exists()
def _zuzhang_meets_threshold(self, user):
"""组长:已是组长身份"""
return UserZuzhang.query.filter(user=user).exists()
def _boss_meets_threshold(self, user):
"""老板:有下单记录"""
try:
boss = UserBoss.query.get(user=user)
except UserBoss.DoesNotExist:
return False
return (boss.alldingdan or 0) > 0
import io
import os
import re
import time
import random
import secrets
import hashlib
import threading
import urllib.parse
from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.db import transaction, connection
from django.db.models import Q, F, Max, Prefetch
from gvsdsdk.fluent import db, func, FQ
from django.core.cache import cache
from django.core.paginator import Paginator
from django.utils import timezone
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status, permissions
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.parsers import JSONParser, MultiPartParser, FormParser
from rest_framework.throttling import AnonRateThrottle, SimpleRateThrottle
from utils.oss_utils import upload_to_oss, delete_from_oss, validate_image
from utils.weixin_broadcast import WeixinBroadcastSender
from utils.weixin_token import get_weixin_mini_access_token, is_weixin_token_invalid
# from utils.ip_security import *
from utils.invitationcode_utils import CreateInvitationCode, VerifyInvitationCode
from backend.utils import update_shangjia_daily
from utils.chat_utils import subscribe_merchant_link_chat
from utils.pdd_order_validator import validate_pdd_order_id, validate_cn_mobile
from ..models import (
Gonggao, Lunbo, Tupianpeizhi, Qunpeizhi,
ShangjiaMoban, ShangjiaLianjie, PopupPage, PopupConfig, WithdrawConfig,
MiniappScriptScene, MiniappScriptAutoReply,
)
from users.models import (
AdminProfile, UserDashou, UserBoss,
UserShangjia, UserGuanshi, UserZuzhang
)
from users.business_models import User
from orders.models import CommissionRate, Order, MerchantOrderExt
from orders.notice_tasks import dingdan_guangbo
from rank.models import Chenghao, DingdanBiaoqian
from products.models import ShangpinLeixing, Huiyuangoumai
from ..serializers import PopupConfigSerializer
import traceback
import requests
import logging
logger = logging.getLogger(__name__)
class GetDynamicConfigView(APIView):
permission_classes = [AllowAny]
parser_classes = [JSONParser]
def post(self, request):
try:
from jituan.services.display_config import get_tupianpeizhi_url
from jituan.services.miniapp_assets import build_miniapp_assets_payload
cos_oss_url = getattr(settings, 'COS_DOMAIN', '')
if cos_oss_url and not cos_oss_url.endswith('/'):
cos_oss_url += '/'
cos_bucket = getattr(settings, 'COS_BUCKET', '')
cos_region = getattr(settings, 'COS_REGION', 'ap-shanghai')
goeasy_host = 'hangzhou.goeasy.io'
goeasy_appkey = getattr(settings, 'GOEASY_APPKEY', '')
kefu_link = getattr(settings, 'KEFU_LINK', '')
kefu_enterprise_id = getattr(settings, 'KEFU_ENTERPRISE_ID', '')
rule_img = get_tupianpeizhi_url(request, 1)
avatar_img = get_tupianpeizhi_url(request, 2)
morentouxiang = avatar_img or 'beijing/morentouxiang.jpg'
dashouguize = rule_img or 'a_long/dashouguize.jpg'
# 构造返回数据
data = {
"code": 0,
"data": {
"cos": {
"bucket": cos_bucket,
"region": cos_region,
"ossImageUrl": cos_oss_url,
"uploadPathPrefix": "order/"
},
"goEasy": {
"host": goeasy_host,
"appkey": goeasy_appkey
},
"otherConfig": {
"morentouxiang": morentouxiang,
"dashouguize": dashouguize
},
"kefu": {
"link": kefu_link,
"enterpriseId": kefu_enterprise_id
},
"miniappAssets": build_miniapp_assets_payload(request),
}
}
return Response(data)
except Exception as e:
logger.error(f"获取动态配置失败: {str(e)}", exc_info=True)
return Response({"code": 500, "msg": "服务器内部错误"}, status=500)
class HaibaoPeizhiView(APIView):
"""小程序推广海报背景配置(按俱乐部)。"""
permission_classes = [AllowAny]
parser_classes = [JSONParser]
def post(self, request):
try:
from jituan.services.display_config import _display_club_id
from jituan.services.miniapp_assets import get_poster_payload
club_id = _display_club_id(request)
poster = get_poster_payload(club_id)
return Response({
'code': 0,
'msg': 'success',
'data': {
'club_id': club_id,
'poster': poster,
},
})
except Exception as e:
logger.error('获取海报配置失败: %s', e, exc_info=True)
return Response({'code': 500, 'msg': '服务器内部错误'}, status=500)
class PopupConfigView(APIView):
"""
获取指定页面的弹窗配置
请求格式:POST { "pageKey": "dashouzhongxin" }
返回格式:{ "code": 0, "data": { "serverTime": "...", "popups": [...] } }
"""
authentication_classes = []
permission_classes = [AllowAny]
def post(self, request):
# 获取参数 pageKey
page_key = request.data.get('pageKey')
if not page_key:
return Response({
'code': 400,
'msg': '缺少参数 pageKey'
}, status=status.HTTP_400_BAD_REQUEST)
# 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(club_id=club_id, page_key=page_key, is_active=True)
except PopupPage.DoesNotExist:
# 该页面没有配置弹窗,返回空
return Response({
'code': 0,
'data': {
'serverTime': timezone.now().isoformat(),
'popups': []
}
})
# 4. 查询该页面下所有启用的弹窗(时间窗为空=长期有效)
now = timezone.now()
popups = PopupConfig.query.filter(
page=popup_page,
is_active=True,
).filter(
Q(start_time__isnull=True) | Q(start_time__lte=now),
Q(end_time__isnull=True) | Q(end_time__gte=now),
).prefetch_related('images') # 预加载图片,避免N+1查询
# 按 sort_order 排序(model Meta 已定义)
# 5. 序列化
serializer = PopupConfigSerializer(popups, many=True)
# 6. 返回数据
return Response({
'code': 0,
'data': {
'serverTime': now.isoformat(),
'ignore_user_mute': popup_page.ignore_user_mute, # 新增
'popups': serializer.data
}
})
class GetWithdrawModeView(APIView):
"""
获取提现模式配置
前端请求 /peizhi/hqtxzsym
返回格式: { "code": 0, "data": { "mode": 1 or 2 } }
"""
permission_classes = [IsAuthenticated] # JWT 认证
def post(self, request, *args, **kwargs):
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,
'club_id': club_id,
}
})
class CheckPhoneAuthView(APIView):
"""
判断当前登录用户是否需要强制手机号认证
规则:
- 已认证过手机号(PhoneVerified=True) → 不需要
- 未认证时,满足以下任一条件 → 需要认证:
打手:有会员购买记录,或押金 > 0
商家:已是商家(存在商家扩展表)
管事:已是管事(存在管事扩展表)
组长:已是组长(存在组长扩展表)
老板:有下单记录(alldingdan > 0)
子客服:已是有效商家客服(merchant_staff_member 活跃)
"""
permission_classes = [IsAuthenticated]
@staticmethod
def _phone_auth_completed(user):
"""是否已完成手机号认证(兼容 PhoneVerified 未写入的老数据)。"""
if bool(getattr(user, 'PhoneVerified', False)):
return True
phone = (getattr(user, 'Phone', None) or '').strip()
return len(phone) >= 11
def post(self, request):
uid = getattr(request.user, 'UserUID', None)
user = request.user
if uid:
try:
user = User.query.get(UserUID=uid)
except User.DoesNotExist:
user = request.user
# 俱乐部关闭强制收集手机号 → 前端 ensurePhoneAuth 不再跳转认证页
try:
from jituan.services.club_context import resolve_effective_club_id
from jituan.services.force_phone_auth import is_club_force_phone_auth
club_id = resolve_effective_club_id(request, user)
if not is_club_force_phone_auth(club_id):
return Response({'code': 0, 'need_auth': False})
except Exception:
pass
if self._phone_auth_completed(user):
return Response({'code': 0, 'need_auth': False})
if self._dashou_meets_threshold(user):
return Response({'code': 0, 'need_auth': True})
if self._shangjia_meets_threshold(user):
return Response({'code': 0, 'need_auth': True})
if self._guanshi_meets_threshold(user):
return Response({'code': 0, 'need_auth': True})
if self._zuzhang_meets_threshold(user):
return Response({'code': 0, 'need_auth': True})
if self._boss_meets_threshold(user):
return Response({'code': 0, 'need_auth': True})
if self._staff_meets_threshold(user):
return Response({'code': 0, 'need_auth': True})
return Response({'code': 0, 'need_auth': False})
def _staff_meets_threshold(self, user):
"""子客服:已是有效商家客服"""
try:
from merchant_ops.services.authz import get_active_staff_member
return get_active_staff_member(user) is not None
except Exception:
return False
def _dashou_meets_threshold(self, user):
"""打手:有会员记录、押金 > 0、或有过接单/成交订单"""
try:
dashou = UserDashou.query.get(user=user)
except UserDashou.DoesNotExist:
return False
from jituan.services.fund_freeze import get_effective_yajin
if get_effective_yajin(dashou, user_id=getattr(user, 'UserUID', '')) > 0:
return True
from jituan.services.club_context import resolve_effective_club_id
club_id = resolve_effective_club_id(self.request, user)
if Huiyuangoumai.query.filter(yonghu_id=user.UserUID, club_id=club_id).exists():
return True
if (dashou.jiedanzongliang or 0) > 0 or (dashou.chengjiaozongliang or 0) > 0:
return True
return Order.query.filter(PlayerID=user.UserUID).exists()
def _shangjia_meets_threshold(self, user):
"""商家:已是商家身份"""
return UserShangjia.query.filter(user=user).exists()
def _guanshi_meets_threshold(self, user):
"""管事:已是管事身份"""
return UserGuanshi.query.filter(user=user).exists()
def _zuzhang_meets_threshold(self, user):
"""组长:已是组长身份"""
return UserZuzhang.query.filter(user=user).exists()
def _boss_meets_threshold(self, user):
"""老板:有下单记录"""
try:
boss = UserBoss.query.get(user=user)
except UserBoss.DoesNotExist:
return False
return (boss.alldingdan or 0) > 0
+36 -7
View File
@@ -117,20 +117,37 @@ def fulfill_yajin_recharge(dingdan_id, paid_amount_yuan=None):
except UserDashou.DoesNotExist:
raise CzjiluFulfillError('打手不存在,无法充值押金', 'dashou_not_found')
dashou.yajin += amount
dashou.save(update_fields=['yajin'])
from jituan.services.fund_freeze import (
SOURCE_YAJIN_RECHARGE,
credit_dashou_yajin,
display_yajin_str,
)
club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT
uid = str(order.yonghuid or '')
freeze_ret = credit_dashou_yajin(
user_id=uid,
amount=amount,
biz_id=str(dingdan_id),
profile=dashou,
club_id=club_id,
czjilu_id=str(dingdan_id),
source_type=SOURCE_YAJIN_RECHARGE,
freeze_reason=f'押金充值 {dingdan_id}',
)
if freeze_ret.get('idempotent'):
logger.warning('押金履约幂等跳过入账 dingdan=%s ledger=%s', dingdan_id, freeze_ret.get('ledger_id'))
dashou.refresh_from_db(fields=['yajin', 'dongjie_yue', 'jifen'])
try:
from jituan.services.yajin_jifen_switch import maybe_fill_jifen_after_yajin
club_id_for_jifen = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT
maybe_fill_jifen_after_yajin(dashou, club_id_for_jifen)
maybe_fill_jifen_after_yajin(dashou, club_id)
except Exception as exc:
logger.error('押金补积分失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True)
order.zhuangtai = 3
order.save(update_fields=['zhuangtai'])
club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT
try:
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
repair_wechat_income_if_missing(amount, club_id, biz_ref=f'cz:{dingdan_id}')
@@ -142,8 +159,20 @@ def fulfill_yajin_recharge(dingdan_id, paid_amount_yuan=None):
except Exception as exc:
logger.error('押金分红失败但押金已到账 dingdan=%s: %s', dingdan_id, exc, exc_info=True)
logger.info('押金履约完成 dingdan=%s amount=%s', dingdan_id, amount)
return {'already_done': False, 'dingdan_id': dingdan_id, 'zhuangtai': 3, 'yajin': dashou.yajin}
display_yajin = display_yajin_str(dashou, user_id=uid)
logger.info(
'押金履约完成 dingdan=%s amount=%s available=%s frozen=%s display_yajin=%s',
dingdan_id, amount, freeze_ret.get('available'), freeze_ret.get('frozen'), display_yajin,
)
return {
'already_done': False,
'dingdan_id': dingdan_id,
'zhuangtai': 3,
'yajin': display_yajin,
'yajin_available': str(dashou.yajin),
'yajin_frozen': str(freeze_ret.get('frozen') or 0),
'freeze': freeze_ret,
}
def _apply_yajin_fenhong(order, dashou, amount):
+171 -8
View File
@@ -31,10 +31,82 @@ SOURCE_FENHONG = 'fenhong'
SOURCE_KAOHE_FEE = 'kaohe_fee'
SOURCE_WITHDRAW_REJECT = 'withdraw_reject'
SOURCE_CLUB_MIGRATE = 'club_migrate'
# 押金/保证金入账:进同一 dongjie_yue,解冻回 yajin(绝不能回 yue)
SOURCE_YAJIN_RECHARGE = 'yajin_recharge'
SOURCE_YAJIN_EXCHANGE = 'yajin_exchange'
YAJIN_SOURCE_TYPES = frozenset({SOURCE_YAJIN_RECHARGE, SOURCE_YAJIN_EXCHANGE})
CREDIT_TARGET_YUE = 'yue'
CREDIT_TARGET_YAJIN = 'yajin'
_CFG_CACHE_TTL = 60
def is_yajin_source(source_type: str) -> bool:
return (source_type or '').strip() in YAJIN_SOURCE_TYPES
def _ledger_credit_target(ledger: FundFreezeLedger) -> str:
"""解冻目标:押金类 ledger → yajin;其余 → 可提现佣金。"""
if is_yajin_source(getattr(ledger, 'source_type', '') or ''):
return CREDIT_TARGET_YAJIN
snap = getattr(ledger, 'config_snapshot', None) or {}
if (snap.get('credit_target') or '').strip() == CREDIT_TARGET_YAJIN:
return CREDIT_TARGET_YAJIN
return CREDIT_TARGET_YUE
def get_frozen_yajin_remaining(user_id: str) -> Decimal:
"""押金类冻结单未解冻剩余合计(不含佣金冻结)。"""
uid = str(user_id or '').strip()
if not uid:
return Decimal('0.00')
rows = FundFreezeLedger.query.filter(
user_id=uid,
role=ROLE_DASHOU,
source_type__in=list(YAJIN_SOURCE_TYPES),
status__in=[FundFreezeLedger.STATUS_FROZEN, FundFreezeLedger.STATUS_PARTIAL],
).values_list('amount_total', 'amount_unfrozen')
total = Decimal('0.00')
for amount_total, amount_unfrozen in rows:
remain = _money(amount_total) - _money(amount_unfrozen)
if remain > 0:
total += remain
return _money(total)
def get_effective_yajin(dashou_profile=None, *, user_id: str = '', yajin=None) -> Decimal:
"""
接单资格 / 小程序展示用的「在押总额」= 真实 yajin + 押金冻结剩余。
提现路径禁止使用本函数,必须读库表 yajin。
"""
uid = str(user_id or '').strip()
if yajin is None:
if dashou_profile is not None:
yajin = getattr(dashou_profile, 'yajin', 0) or 0
if not uid:
try:
user = getattr(dashou_profile, 'user', None)
uid = str(getattr(user, 'UserUID', None) or getattr(dashou_profile, 'user_id', '') or '')
except Exception:
uid = ''
else:
yajin = 0
base = _money(yajin)
if not uid and dashou_profile is not None:
try:
user = getattr(dashou_profile, 'user', None)
uid = str(getattr(user, 'UserUID', None) or '')
except Exception:
uid = ''
return _money(base + get_frozen_yajin_remaining(uid))
def display_yajin_str(dashou_profile=None, *, user_id: str = '', yajin=None) -> str:
"""对外 API 伪装字段:前端仍读 yajin,实为在押总额。"""
return str(get_effective_yajin(dashou_profile, user_id=user_id, yajin=yajin))
def _money(v) -> Decimal:
return Decimal(str(v or 0)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
@@ -94,6 +166,19 @@ def calc_freeze_amount(credit: Decimal, cfg: ClubFundFreezeConfig) -> Decimal:
def _source_allowed(cfg: ClubFundFreezeConfig, source_type: str, role: str = '') -> bool:
scope = cfg.source_scope
# 押金冻结必须显式勾选;空 scope 也不自动冻押金,避免误伤现网
if is_yajin_source(source_type):
if not scope:
return False
if isinstance(scope, str):
items = [scope]
else:
try:
items = list(scope)
except TypeError:
return False
return source_type in items
if not scope:
return True
if isinstance(scope, str):
@@ -148,11 +233,25 @@ def _first_step_delay_days(cfg: ClubFundFreezeConfig) -> int:
return max(0, int(cfg.freeze_min_days or 0))
def _add_withdrawable(role: str, profile, available: Decimal, also_zonge: bool = False):
"""原子增加可提现;组长字段为 ketixian_jine。"""
def _add_withdrawable(
role: str,
profile,
available: Decimal,
also_zonge: bool = False,
*,
credit_target: str = CREDIT_TARGET_YUE,
):
"""原子增加可提现(或押金可提部分);组长字段为 ketixian_jine。"""
available = _money(available)
if available == 0:
return
if credit_target == CREDIT_TARGET_YAJIN:
if role != ROLE_DASHOU:
raise ValueError('押金入账仅支持打手角色')
type(profile).query.filter(pk=profile.pk).update(
yajin=F('yajin') + available,
)
return
if role == ROLE_ZUZHANG:
type(profile).query.filter(pk=profile.pk).update(
ketixian_jine=F('ketixian_jine') + available,
@@ -187,6 +286,7 @@ def credit_role_balance(
freeze_reason: str = '',
also_credit_zonge: bool = False,
ignore_source_scope: bool = False,
credit_target: str = CREDIT_TARGET_YUE,
) -> Dict[str, Any]:
"""
统一入账。返回 {available, frozen, ledger_id, skipped_freeze}。
@@ -194,12 +294,18 @@ def credit_role_balance(
调用方须在外层 transaction.atomic() 内,并已持有/即将更新的业务一致性。
profile: 已加载的角色扩展实例(UserDashou 等);若空则按 role+user_id 查。
ignore_source_scope: True 时跳过 source_scope 检查(仍受 enabled / 金额规则约束)。
credit_target: yue=可提现佣金(默认);yajin=押金可提部分(冻结进同一 dongjie_yue)。
"""
role = (role or '').strip().lower()
cid = _club(club_id)
credit = _money(amount)
biz_id = (biz_id or '').strip()
source_type = (source_type or '').strip() or 'unknown'
credit_target = (credit_target or CREDIT_TARGET_YUE).strip() or CREDIT_TARGET_YUE
if is_yajin_source(source_type):
credit_target = CREDIT_TARGET_YAJIN
if credit_target == CREDIT_TARGET_YAJIN and role != ROLE_DASHOU:
raise ValueError('押金入账仅支持打手角色')
if credit <= 0:
return {'available': Decimal('0.00'), 'frozen': Decimal('0.00'), 'ledger_id': None, 'skipped_freeze': True}
@@ -239,7 +345,7 @@ def credit_role_balance(
getattr(cfg, 'freeze_mode', None), getattr(cfg, 'freeze_rate', None),
)
# 幂等:已存在同 biz 冻结单则只补可提现差额不重复冻(极端重入)
# 幂等:已存在同 biz 冻结单则不再入账(极端重入)
if freeze_on and frozen > 0:
existed = FundFreezeLedger.query.filter(
club_id=cid, role=role, source_type=source_type, biz_id=biz_id,
@@ -258,12 +364,16 @@ def credit_role_balance(
'skip_reason': 'idempotent',
}
_add_withdrawable(role, profile, available, also_zonge=also_credit_zonge)
_add_withdrawable(
role, profile, available, also_zonge=also_credit_zonge, credit_target=credit_target,
)
ledger_id = None
if frozen > 0:
_add_dongjie(profile, frozen)
delay = _first_step_delay_days(cfg)
next_at = timezone.now() + timedelta(days=delay) if delay else timezone.now()
snap = _config_snapshot(cfg)
snap['credit_target'] = credit_target
ledger = FundFreezeLedger.query.create(
club_id=cid,
user_id=str(user_id),
@@ -280,15 +390,15 @@ def credit_role_balance(
or (getattr(cfg, 'remark', None) if cfg else None)
or f'{source_type}冻结'
)[:255],
config_snapshot=_config_snapshot(cfg),
config_snapshot=snap,
current_step=1,
next_eligible_at=next_at,
)
ledger_id = ledger.id
logger.info(
'fund_freeze applied club=%s role=%s source=%s biz=%s user=%s '
'fund_freeze applied club=%s role=%s source=%s target=%s biz=%s user=%s '
'credit=%s available=%s frozen=%s ledger=%s',
cid, role, source_type, biz_id, user_id, credit, available, frozen, ledger_id,
cid, role, source_type, credit_target, biz_id, user_id, credit, available, frozen, ledger_id,
)
return {
@@ -298,6 +408,7 @@ def credit_role_balance(
'skipped_freeze': frozen <= 0,
'skip_reason': skip_reason or None,
'club_id': cid,
'credit_target': credit_target,
}
@@ -484,6 +595,45 @@ def credit_dashou_order_settle(
return result
def credit_dashou_yajin(
*,
user_id: str,
amount,
biz_id: str,
profile=None,
club_id: Optional[str] = None,
czjilu_id: str = '',
source_type: str = SOURCE_YAJIN_RECHARGE,
freeze_reason: str = '',
) -> Dict[str, Any]:
"""
打手押金入账网关。
- 未开启冻结 / 未勾选押金来源:全额进 yajin(与现网一致)
- 开启且勾选:按规则拆「可提押金 yajin + 冻结池 dongjie_yue」,ledger.source=yajin_*
- 解冻时回 yajin,绝不进佣金 yue
调用方须在 transaction.atomic() 内。
"""
uid = str(user_id or '').strip()
src = (source_type or SOURCE_YAJIN_RECHARGE).strip()
if not is_yajin_source(src):
src = SOURCE_YAJIN_RECHARGE
cid = settle_freeze_club_id(user_id=uid) if not club_id else _club(club_id)
return credit_role_balance(
club_id=cid,
user_id=uid,
role=ROLE_DASHOU,
amount=amount,
source_type=src,
biz_id=str(biz_id or '').strip(),
profile=profile,
czjilu_id=(czjilu_id or biz_id or '')[:64],
freeze_reason=freeze_reason or '押金充值冻结',
also_credit_zonge=False,
credit_target=CREDIT_TARGET_YAJIN,
)
def _step_unfreeze_amount(ledger: FundFreezeLedger, step: dict) -> Decimal:
remain = _money(ledger.amount_total - ledger.amount_unfrozen)
if remain <= 0:
@@ -654,7 +804,20 @@ def apply_unfreeze_step(
if amount <= 0:
return {'ok': False, 'msg': '冻结池余额不足'}
if ledger.role == ROLE_ZUZHANG:
# 押金冻结必须解回 yajin;佣金/分红解回可提现(组长 ketixian_jine)
credit_target = _ledger_credit_target(ledger)
if credit_target == CREDIT_TARGET_YAJIN:
if ledger.role != ROLE_DASHOU:
return {'ok': False, 'msg': '押金冻结单角色异常,拒绝解冻'}
model.query.filter(pk=profile.pk).update(
dongjie_yue=F('dongjie_yue') - amount,
yajin=F('yajin') + amount,
)
logger.info(
'fund_unfreeze to_yajin ledger=%s user=%s amount=%s',
ledger.id, ledger.user_id, amount,
)
elif ledger.role == ROLE_ZUZHANG:
model.query.filter(pk=profile.pk).update(
dongjie_yue=F('dongjie_yue') - amount,
ketixian_jine=F('ketixian_jine') + amount,
+12
View File
@@ -289,6 +289,18 @@ def list_ledgers(request, data: dict) -> Dict[str, Any]:
'frozen_at': r.frozen_at.isoformat() if r.frozen_at else '',
'next_eligible_at': r.next_eligible_at.isoformat() if r.next_eligible_at else '',
'source_type': r.source_type,
'source_label': {
'order_settle': '订单结算',
'fenhong': '分红/分佣',
'withdraw_reject': '提现驳回',
'club_migrate': '跨店迁移',
'kaohe_fee': '考核代收',
'yajin_recharge': '押金充值',
'yajin_exchange': '兑换押金',
}.get(r.source_type or '', r.source_type or ''),
'credit_target': ((r.config_snapshot or {}).get('credit_target')
or ('yajin' if (r.source_type or '') in (
'yajin_recharge', 'yajin_exchange') else 'yue')),
'order_id': r.order_id,
'czjilu_id': r.czjilu_id,
'biz_id': r.biz_id,
+4 -1
View File
@@ -99,7 +99,10 @@ def resolve_fake_pool_mode(user, club_id=None, leixing_id=None):
return MODE_REAL_ONLY
try:
yajin = Decimal(str(getattr(dashou, 'yajin', 0) or 0))
from jituan.services.fund_freeze import get_effective_yajin
yajin = get_effective_yajin(
dashou, user_id=str(getattr(user, 'UserUID', '') or ''),
)
except Exception:
yajin = Decimal('0')
if yajin >= get_fake_yajin_hide_threshold(cid):
+3 -1
View File
@@ -569,8 +569,10 @@ def join_recruit(*, recruit_id: int, user_uid: str, game_id: str, request=None,
if not user_has_huiyuan_access_any(user_uid, required_ids, club_id=rec.club_id):
raise ValueError('未开通对应会员或会员已过期')
elif order.GrabRequirement == 2:
from jituan.services.fund_freeze import get_effective_yajin
yajin_required = order.CommissionReq or 0
if (profile.yajin or 0) < yajin_required:
if get_effective_yajin(profile) < yajin_required:
raise ValueError(f'押金不足,需要{yajin_required}元')
now = timezone.now()
+4 -2
View File
@@ -426,6 +426,8 @@ class AdGengHuanDaShou(APIView):
)
elif yaoqiuleixing == 2: # 押金抢单
from jituan.services.fund_freeze import get_effective_yajin
# 获取订单的押金(yongjin字段)
order_yajin = dingdan_obj.CommissionReq
if order_yajin is None:
@@ -434,8 +436,8 @@ class AdGengHuanDaShou(APIView):
status=status.HTTP_400_BAD_REQUEST
)
# 验证打手押金是否大于等于订单押金
if new_dashou_profile.yajin < order_yajin:
# 验证打手押金是否大于等于订单押金(含押金冻结池)
if get_effective_yajin(new_dashou_profile) < order_yajin:
return Response(
{'code': 400, 'message': '打手押金不足', 'data': None},
status=status.HTTP_400_BAD_REQUEST
+3 -2
View File
@@ -679,14 +679,15 @@ class QiangdanView(APIView):
}
def _validate_yajin_requirement(self, order, dashou_profile):
from jituan.services.fund_freeze import get_effective_yajin
yajin_required = order.CommissionReq or 0
dashou_yajin = dashou_profile.yajin or 0
try:
need = float(yajin_required)
except (TypeError, ValueError):
need = 0.0
try:
have = float(dashou_yajin)
have = float(get_effective_yajin(dashou_profile))
except (TypeError, ValueError):
have = 0.0
if have < need:
+21 -8
View File
@@ -1092,20 +1092,33 @@ class DsqrgmdhView(APIView):
elif leixing == 2:
try:
dashou = UserDashou.objects.select_for_update().get(user=user)
dashou.yajin += yajin_jine
dashou.save(update_fields=['yajin'])
from jituan.services.fund_freeze import (
SOURCE_YAJIN_EXCHANGE,
credit_dashou_yajin,
display_yajin_str,
)
from jituan.services.club_user import get_user_club_id
yajin_club = getattr(czjilu, 'club_id', None) or get_user_club_id(user) or ''
uid = str(getattr(user, 'UserUID', '') or '')
credit_dashou_yajin(
user_id=uid,
amount=yajin_jine,
biz_id=str(getattr(czjilu, 'dingdan_id', None) or czjilu.pk),
profile=dashou,
club_id=yajin_club,
czjilu_id=str(getattr(czjilu, 'dingdan_id', None) or ''),
source_type=SOURCE_YAJIN_EXCHANGE,
freeze_reason='余额兑换押金',
)
dashou.refresh_from_db(fields=['yajin', 'dongjie_yue', 'jifen'])
try:
from jituan.services.yajin_jifen_switch import maybe_fill_jifen_after_yajin
from jituan.services.club_user import get_user_club_id
maybe_fill_jifen_after_yajin(
dashou,
getattr(czjilu, 'club_id', None) or get_user_club_id(user) or '',
)
maybe_fill_jifen_after_yajin(dashou, yajin_club)
except Exception as jifen_exc:
logger.error('余额抵扣押金补积分失败: %s', jifen_exc, exc_info=True)
czjilu.zhuangtai = MEMBER_PAID_STATUS
czjilu.save(update_fields=['zhuangtai'])
response_data['yajin'] = dashou.yajin
response_data['yajin'] = display_yajin_str(dashou, user_id=uid)
response_data['jifen'] = dashou.jifen
except UserDashou.DoesNotExist:
setattr(locked_profile, identity['balance_field'], current_balance)
+6 -2
View File
@@ -627,10 +627,14 @@ class YajinLunxun(APIView):
'message': '订单尚未支付完成'
}, status=status.HTTP_200_OK)
# 4. 获取打手押金(通过反向查询,最快捷的方式)
# 4. 获取打手押金(对外=在押总额,含押金冻结池,供前端全局变量)
try:
from jituan.services.fund_freeze import get_effective_yajin
dashou = request.user.DashouProfile
yajin = float(dashou.yajin) # 转为浮点数方便前端处理
yajin = float(get_effective_yajin(
dashou, user_id=str(request.user.UserUID),
))
logger.info(f"轮询成功: 订单{dingdanid}已支付,用户{request.user.UserUID}押金为{yajin}元")
+4 -4
View File
@@ -651,7 +651,7 @@ class DashouZhuceView(APIView):
'zhanghaostatus': dashou_profile.zhanghaozhuangtai,
'yongjin': str(dashou_profile.yue), # Decimal转字符串
'zonge': str(dashou_profile.zonge),
'yajin': str(dashou_profile.yajin),
'yajin': __import__('jituan.services.fund_freeze', fromlist=['display_yajin_str']).display_yajin_str(dashou_profile),
'chenghao': dashou_profile.chenghao,
'jinfen': dashou_profile.jifen,
'chengjiaoliang': dashou_profile.chengjiaozongliang,
@@ -1259,7 +1259,7 @@ class WechatLoginAndDashouRegisterView(APIView):
'zhanghaostatus': dashou_profile.zhanghaozhuangtai,
'yongjin': str(dashou_profile.yue), # Decimal转字符串
'zonge': str(dashou_profile.zonge),
'yajin': str(dashou_profile.yajin),
'yajin': __import__('jituan.services.fund_freeze', fromlist=['display_yajin_str']).display_yajin_str(dashou_profile),
'chenghao': dashou_profile.chenghao,
'jinfen': dashou_profile.jifen,
'chengjiaoliang': dashou_profile.chengjiaozongliang,
@@ -1462,7 +1462,7 @@ class WechatLoginAndGuanshiRegisterView(APIView):
'zhanghaostatus': dashou_profile.zhanghaozhuangtai,
'yongjin': str(dashou_profile.yue),
'zonge': str(dashou_profile.zonge),
'yajin': str(dashou_profile.yajin),
'yajin': __import__('jituan.services.fund_freeze', fromlist=['display_yajin_str']).display_yajin_str(dashou_profile),
'chenghao': dashou_profile.chenghao,
'jinfen': dashou_profile.jifen,
'chengjiaoliang': dashou_profile.chengjiaozongliang,
@@ -1687,7 +1687,7 @@ class GuanshiRegisterView(APIView):
'zhanghaostatus': dashou_profile.zhanghaozhuangtai,
'yongjin': str(dashou_profile.yue),
'zonge': str(dashou_profile.zonge),
'yajin': str(dashou_profile.yajin),
'yajin': __import__('jituan.services.fund_freeze', fromlist=['display_yajin_str']).display_yajin_str(dashou_profile),
'chenghao': dashou_profile.chenghao,
'jinfen': dashou_profile.jifen,
'chengjiaoliang': dashou_profile.chengjiaozongliang,
+3 -1
View File
@@ -602,11 +602,13 @@ class KefuChangeDashouView(APIView):
return Response({'code': 400, 'msg': '打手的会员已过期'}, status=status.HTTP_400_BAD_REQUEST)
elif yaoqiuleixing == 2: # 押金抢单
from jituan.services.fund_freeze import get_effective_yajin
order_yajin = order.CommissionReq # 假设订单的 yongjin 字段存储押金要求
if order_yajin is None:
return Response({'code': 400, 'msg': '订单押金为空'}, status=status.HTTP_400_BAD_REQUEST)
if new_dashou_profile.yajin < order_yajin:
if get_effective_yajin(new_dashou_profile) < order_yajin:
return Response({'code': 400, 'msg': '打手押金不足'}, status=status.HTTP_400_BAD_REQUEST)
else:
return Response({'code': 400, 'msg': '订单抢单要求类型错误'}, status=status.HTTP_400_BAD_REQUEST)
+6 -4
View File
@@ -399,7 +399,8 @@ class DashouXinxiAPIView(APIView):
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
# 3. 构建基础信息响应(原有字段完全保留)
# 3. 构建基础信息响应(yajin 对外=在押总额,含押金冻结)
from jituan.services.fund_freeze import display_yajin_str
data = {
'dashounicheng': dashou_profile.nicheng or '',
'zhanghaostatus': dashou_profile.zhanghaozhuangtai,
@@ -409,7 +410,7 @@ class DashouXinxiAPIView(APIView):
'yongjin': str(dashou_profile.yue) if dashou_profile.yue is not None else '0.00',
'dongjie_yue': str(getattr(dashou_profile, 'dongjie_yue', 0) or 0),
'zonge': str(dashou_profile.zonge) if dashou_profile.zonge is not None else '0.00',
'yajin': str(dashou_profile.yajin) if dashou_profile.yajin is not None else '0.00',
'yajin': display_yajin_str(dashou_profile, user_id=getattr(current_user, 'UserUID', '')),
'jiedanZongliang':dashou_profile.jiedanzongliang if dashou_profile.jiedanzongliang is not None else '0',
'zuijinTixian': str(dashou_profile.jinritixian_jine) if dashou_profile.jinritixian_jine is not None else '0.00',
'chengjiaoliang': dashou_profile.chengjiaozongliang,
@@ -1155,10 +1156,11 @@ class DashouJianquanView(APIView):
'msg': '鉴权通过(俱乐部允许无会员聊天)'
})
# ========== 3. 押金达到俱乐部门槛 ==========
# ========== 3. 押金达到俱乐部门槛(含押金冻结池) ==========
from jituan.services.fund_freeze import get_effective_yajin
threshold = get_dashou_chat_yajin_threshold(club_id)
try:
yajin = Decimal(str(getattr(dashou, 'yajin', 0) or 0))
yajin = get_effective_yajin(dashou, user_id=getattr(user, 'UserUID', ''))
except Exception:
yajin = Decimal('0')
if yajin >= threshold: