feat: 资金冻结 P2 客服后台 API(配置/明细/风险/手动解冻)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-26 18:55:26 +08:00
parent 6a1895e566
commit 3194a47f52
4 changed files with 500 additions and 0 deletions

View File

@@ -330,6 +330,9 @@ class KefuGetDashouDetailView(APIView):
'tuikuanliang': dashou.tuikuanliang,
'jinrijiedan': dashou.jinrijiedan,
'yue': float(dashou.yue) if dashou.yue else 0.00,
'dongjie_yue': float(getattr(dashou, 'dongjie_yue', 0) or 0),
'risk_fine_rate': float(getattr(dashou, 'risk_fine_rate', 0) or 0),
'risk_complaint_rate': float(getattr(dashou, 'risk_complaint_rate', 0) or 0),
'zonge': float(dashou.zonge) if dashou.zonge else 0.00,
'jifen': dashou.jifen,
'yajin': float(dashou.yajin) if dashou.yajin else 0.00,

View File

@@ -0,0 +1,384 @@
"""客服后台:资金冻结配置 / 明细 / 风险指标。"""
from __future__ import annotations
import json
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
from django.db.models import Q
from jituan.constants import CLUB_ID_DEFAULT
from jituan.models import ClubFundFreezeConfig, FundFreezeLedger, FundUnfreezeEvent
from jituan.services.club_context import filter_user_related_by_club, resolve_club_id_from_request
from jituan.services.fund_freeze import (
ROLE_DASHOU,
ROLE_GUANSHI,
ROLE_SHENHEGUAN,
ROLE_ZUZHANG,
apply_unfreeze_step,
invalidate_freeze_config_cache,
split_preview,
)
ROLES = (
(ROLE_DASHOU, '打手'),
(ROLE_GUANSHI, '管事'),
(ROLE_ZUZHANG, '组长'),
(ROLE_SHENHEGUAN, '考核官'),
)
DEFAULT_INSTALLMENT = {
'steps': [
{'seq': 1, 'after_days': 7, 'type': 'ratio', 'value': 0.3, 'need_condition': False},
{'seq': 2, 'after_days': 14, 'type': 'ratio', 'value': 0.3, 'need_condition': True},
{'seq': 3, 'after_days': 30, 'type': 'remain', 'value': 1, 'need_condition': True},
]
}
def _club(request) -> str:
return (resolve_club_id_from_request(request) or CLUB_ID_DEFAULT).strip() or CLUB_ID_DEFAULT
def _dec(v, default='0') -> str:
try:
return str(Decimal(str(v if v is not None else default)))
except Exception:
return str(default)
def _cfg_to_dict(row: Optional[ClubFundFreezeConfig], club_id: str, role: str) -> Dict[str, Any]:
if not row:
return {
'club_id': club_id,
'role': role,
'enabled': False,
'freeze_mode': 1,
'freeze_rate': '0',
'freeze_fixed': '0',
'freeze_cap': '0',
'min_credit_to_freeze': '0',
'source_scope': ['order_settle'],
'unfreeze_trigger': 'time_only',
'schedule_mode': 'all_at_once',
'installment_json': {'steps': [{'seq': 1, 'after_days': 15, 'type': 'remain', 'value': 1, 'need_condition': False}]},
'condition_json': {
'min_completed_orders': 0,
'max_fine_rate': 1,
'fine_rate_window_days': 30,
'max_complaint_rate': 1,
'complaint_rate_window_days': 30,
'min_valid_invites': 0,
},
'freeze_min_days': 0,
'remark': '',
'exists': False,
}
return {
'club_id': row.club_id,
'role': row.role,
'enabled': bool(row.enabled),
'freeze_mode': int(row.freeze_mode or 1),
'freeze_rate': _dec(row.freeze_rate),
'freeze_fixed': _dec(row.freeze_fixed),
'freeze_cap': _dec(row.freeze_cap),
'min_credit_to_freeze': _dec(row.min_credit_to_freeze),
'source_scope': row.source_scope or [],
'unfreeze_trigger': row.unfreeze_trigger or 'time_only',
'schedule_mode': row.schedule_mode or 'all_at_once',
'installment_json': row.installment_json or {},
'condition_json': row.condition_json or {},
'freeze_min_days': int(row.freeze_min_days or 0),
'remark': row.remark or '',
'updated_by': row.updated_by or '',
'exists': True,
}
def build_config_payload(request) -> Dict[str, Any]:
club_id = _club(request)
rows = {
r.role: r
for r in ClubFundFreezeConfig.query.filter(club_id=club_id)
}
items = []
for role, name in ROLES:
d = _cfg_to_dict(rows.get(role), club_id, role)
d['role_name'] = name
avail, frozen = split_preview(club_id, role, Decimal('100'), 'order_settle')
d['preview_100'] = {'available': str(avail), 'frozen': str(frozen)}
items.append(d)
return {'club_id': club_id, 'roles': items}
def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict], Optional[str]]:
club_id = _club(request)
role = (data.get('role') or '').strip().lower()
if role not in {r[0] for r in ROLES}:
return None, '无效角色'
enabled = bool(data.get('enabled'))
freeze_mode = int(data.get('freeze_mode') or 1)
if freeze_mode not in (1, 2, 3):
return None, 'freeze_mode 无效'
try:
freeze_rate = Decimal(str(data.get('freeze_rate') or 0))
freeze_fixed = Decimal(str(data.get('freeze_fixed') or 0))
freeze_cap = Decimal(str(data.get('freeze_cap') or 0))
min_credit = Decimal(str(data.get('min_credit_to_freeze') or 0))
except Exception:
return None, '金额/比例格式错误'
if freeze_rate < 0 or freeze_rate > 1:
return None, '冻结比例须在 0~1 之间(如 0.2=20%'
source_scope = data.get('source_scope')
if source_scope is None:
source_scope = ['order_settle']
if isinstance(source_scope, str):
try:
source_scope = json.loads(source_scope)
except Exception:
source_scope = [source_scope]
unfreeze_trigger = (data.get('unfreeze_trigger') or 'time_only').strip()
if unfreeze_trigger not in (
'time_only', 'condition_only', 'time_and_condition', 'time_or_condition', 'manual_only',
):
return None, '解冻触发模式无效'
schedule_mode = (data.get('schedule_mode') or 'all_at_once').strip()
if schedule_mode not in ('all_at_once', 'installment'):
return None, '分期模式无效'
installment_json = data.get('installment_json') or {}
if isinstance(installment_json, str):
try:
installment_json = json.loads(installment_json)
except Exception:
return None, 'installment_json 不是合法 JSON'
if schedule_mode == 'installment' and not (installment_json.get('steps') or []):
installment_json = DEFAULT_INSTALLMENT
condition_json = data.get('condition_json') or {}
if isinstance(condition_json, str):
try:
condition_json = json.loads(condition_json)
except Exception:
return None, 'condition_json 不是合法 JSON'
freeze_min_days = int(data.get('freeze_min_days') or 0)
if freeze_min_days < 0:
return None, '最短冻结天数不能为负'
row = ClubFundFreezeConfig.query.filter(club_id=club_id, role=role).first()
if not row:
row = ClubFundFreezeConfig(
club_id=club_id,
role=role,
)
row.enabled = enabled
row.freeze_mode = freeze_mode
row.freeze_rate = freeze_rate
row.freeze_fixed = freeze_fixed
row.freeze_cap = freeze_cap
row.min_credit_to_freeze = min_credit
row.source_scope = source_scope
row.unfreeze_trigger = unfreeze_trigger
row.schedule_mode = schedule_mode
row.installment_json = installment_json
row.condition_json = condition_json
row.freeze_min_days = freeze_min_days
row.remark = (data.get('remark') or '')[:255]
row.updated_by = (operator or '')[:32]
row.save()
invalidate_freeze_config_cache(club_id, role)
out = _cfg_to_dict(row, club_id, role)
out['role_name'] = dict(ROLES).get(role, role)
return out, None
STATUS_LABEL = {
FundFreezeLedger.STATUS_FROZEN: '冻结中',
FundFreezeLedger.STATUS_PARTIAL: '部分解冻',
FundFreezeLedger.STATUS_DONE: '已解冻',
FundFreezeLedger.STATUS_VOID: '已作废',
}
def list_ledgers(request, data: dict) -> Dict[str, Any]:
club_id = _club(request)
page = max(1, int(data.get('page') or 1))
page_size = min(100, max(1, int(data.get('page_size') or 20)))
role = (data.get('role') or '').strip().lower()
status = data.get('status')
user_id = (data.get('user_id') or data.get('yonghuid') or '').strip()
order_id = (data.get('order_id') or '').strip()
kw = (data.get('keyword') or '').strip()
qs = FundFreezeLedger.query.filter(club_id=club_id)
if role:
qs = qs.filter(role=role)
if status not in (None, ''):
try:
qs = qs.filter(status=int(status))
except (TypeError, ValueError):
pass
if user_id:
qs = qs.filter(user_id=user_id)
if order_id:
qs = qs.filter(order_id=order_id)
if kw:
qs = qs.filter(Q(user_id__icontains=kw) | Q(order_id__icontains=kw) | Q(biz_id__icontains=kw))
total = qs.count()
rows = qs.order_by('-id')[(page - 1) * page_size: page * page_size]
items = []
for r in rows:
remain = Decimal(str(r.amount_total or 0)) - Decimal(str(r.amount_unfrozen or 0))
items.append({
'id': r.id,
'club_id': r.club_id,
'user_id': r.user_id,
'role': r.role,
'role_name': dict(ROLES).get(r.role, r.role),
'amount_total': _dec(r.amount_total),
'amount_unfrozen': _dec(r.amount_unfrozen),
'amount_remain': _dec(remain),
'status': r.status,
'status_label': STATUS_LABEL.get(r.status, str(r.status)),
'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,
'order_id': r.order_id,
'czjilu_id': r.czjilu_id,
'biz_id': r.biz_id,
'freeze_reason': r.freeze_reason,
'current_step': r.current_step,
})
return {
'club_id': club_id,
'list': items,
'total': total,
'page': page,
'page_size': page_size,
}
def list_unfreeze_events(ledger_id: int, club_id: str) -> List[dict]:
qs = FundUnfreezeEvent.query.filter(ledger_id=ledger_id, club_id=club_id).order_by('-id')[:50]
return [{
'id': e.id,
'step_seq': e.step_seq,
'amount': _dec(e.amount),
'unfrozen_at': e.unfrozen_at.isoformat() if e.unfrozen_at else '',
'trigger': e.trigger,
'reason': e.reason,
'operator_id': e.operator_id,
} for e in qs]
def manual_unfreeze(request, data: dict, operator: str = '') -> Tuple[Optional[dict], Optional[str]]:
club_id = _club(request)
try:
ledger_id = int(data.get('ledger_id') or data.get('id') or 0)
except (TypeError, ValueError):
return None, 'ledger_id 无效'
if ledger_id <= 0:
return None, '缺少 ledger_id'
ledger = FundFreezeLedger.query.filter(id=ledger_id, club_id=club_id).first()
if not ledger:
return None, '冻结单不存在或不属于当前俱乐部'
mode = (data.get('mode') or 'step').strip() # step | all
if mode == 'all':
# 循环解完(最多 20 步防死循环)
last = None
for _ in range(20):
last = apply_unfreeze_step(
ledger_id,
trigger='manual',
operator_id=operator,
reason=(data.get('reason') or '后台手动全部解冻')[:255],
force=True,
)
if not last.get('ok'):
return None, last.get('msg') or '解冻失败'
if last.get('done'):
break
return last, None
result = apply_unfreeze_step(
ledger_id,
trigger='manual',
operator_id=operator,
reason=(data.get('reason') or '后台手动解冻一步')[:255],
force=True,
)
if not result.get('ok'):
return None, result.get('msg') or '解冻失败'
return result, None
def list_risk_users(request, data: dict) -> Dict[str, Any]:
"""从角色扩展表读风险快照 + 冻结池(按俱乐部用户过滤)。"""
from users.models import UserDashou, UserGuanshi, UserZuzhang, UserShenheguan
from jituan.services.club_context import filter_user_related_by_club
club_id = _club(request)
role = (data.get('role') or ROLE_DASHOU).strip().lower()
page = max(1, int(data.get('page') or 1))
page_size = min(100, max(1, int(data.get('page_size') or 20)))
keyword = (data.get('keyword') or '').strip()
only_frozen = bool(data.get('only_frozen'))
mapping = {
ROLE_DASHOU: UserDashou,
ROLE_GUANSHI: UserGuanshi,
ROLE_ZUZHANG: UserZuzhang,
ROLE_SHENHEGUAN: UserShenheguan,
}
model = mapping.get(role)
if not model:
return {'club_id': club_id, 'list': [], 'total': 0, 'page': page, 'page_size': page_size}
qs = model.query.select_related('user').all()
qs = filter_user_related_by_club(qs, request, user_club_path='user__ClubID')
if keyword:
qs = qs.filter(user__UserUID__icontains=keyword)
if only_frozen:
qs = qs.filter(dongjie_yue__gt=0)
total = qs.count()
rows = qs.order_by('-dongjie_yue', '-id')[(page - 1) * page_size: page * page_size]
items = []
for p in rows:
uid = getattr(p.user, 'UserUID', '') if p.user_id else ''
if role == ROLE_ZUZHANG:
available = _dec(getattr(p, 'ketixian_jine', 0))
else:
available = _dec(getattr(p, 'yue', 0))
items.append({
'user_id': uid,
'role': role,
'role_name': dict(ROLES).get(role, role),
'available': available,
'dongjie_yue': _dec(getattr(p, 'dongjie_yue', 0)),
'risk_fine_rate': _dec(getattr(p, 'risk_fine_rate', 0)),
'risk_complaint_rate': _dec(getattr(p, 'risk_complaint_rate', 0)),
'risk_completed_orders': int(getattr(p, 'risk_completed_orders', 0) or 0),
'risk_valid_invites': int(getattr(p, 'risk_valid_invites', 0) or 0),
'risk_stat_at': p.risk_stat_at.isoformat() if getattr(p, 'risk_stat_at', None) else '',
'nicheng': getattr(p, 'nicheng', '') or '',
})
return {
'club_id': club_id,
'role': role,
'list': items,
'total': total,
'page': page,
'page_size': page_size,
}

View File

@@ -101,6 +101,11 @@ from jituan.views_recharge_page_copy import (
RechargePageCopyView,
)
from jituan.views_order_grab_share import ClubOrderGrabLinkManageView
from jituan.views_fund_freeze import (
FundFreezeConfigView,
FundFreezeLedgerView,
FundFreezeRiskView,
)
urlpatterns = [
path('auth/wechat-login', ClubWechatLoginView.as_view(), name='jituan_wechat_login'),
@@ -112,6 +117,9 @@ urlpatterns = [
path('auth/dashou-register', ClubDashouRegisterView.as_view(), name='jituan_dashou_register'),
path('houtai/club-manage', ClubManageView.as_view(), name='jituan_club_manage'),
path('houtai/order-grab-link', ClubOrderGrabLinkManageView.as_view(), name='jituan_order_grab_link'),
path('houtai/fund-freeze-config', FundFreezeConfigView.as_view(), name='jituan_fund_freeze_config'),
path('houtai/fund-freeze-ledger', FundFreezeLedgerView.as_view(), name='jituan_fund_freeze_ledger'),
path('houtai/fund-freeze-risk', FundFreezeRiskView.as_view(), name='jituan_fund_freeze_risk'),
path('houtai/payment-channel-manage', ClubPaymentChannelManageView.as_view(), name='jituan_payment_channel_manage'),
path('houtai/club-cert-upload', ClubCertUploadView.as_view(), name='jituan_club_cert_upload'),
path('houtai/admin-assignments', ClubAdminAssignmentListView.as_view(), name='jituan_admin_assignments'),

105
jituan/views_fund_freeze.py Normal file
View File

@@ -0,0 +1,105 @@
"""客服后台资金冻结 API。"""
from rest_framework.parsers import JSONParser
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 jituan.services import fund_freeze_admin as admin_svc
PERM_CFG = 'djcfg001'
PERM_LED = 'djled001'
PERM_RISK = 'djrisk001'
def _auth(request, perm_code: str):
username = (request.data.get('username') or request.data.get('phone') or '').strip()
if not username:
return None, None, Response({'code': 400, 'msg': '缺少username'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return None, None, permissions
codes = set(permissions or [])
user = getattr(request, 'user', None)
if getattr(user, 'IsSuperuser', False) or '000001' in codes:
return kefu, permissions, None
if perm_code not in codes:
return None, None, Response({'code': 403, 'msg': f'无权限(需要 {perm_code}'})
return kefu, permissions, None
class FundFreezeConfigView(APIView):
"""
POST /jituan/houtai/fund-freeze-config
action: get | save
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
kefu, permissions, err = _auth(request, PERM_CFG)
if err:
return err
action = (request.data.get('action') or 'get').strip().lower()
if action == 'get':
return Response({'code': 0, 'msg': 'ok', 'data': admin_svc.build_config_payload(request)})
if action == 'save':
operator = getattr(getattr(request, 'user', None), 'UserUID', '') or ''
data, msg = admin_svc.save_config(request, request.data, operator=operator)
if msg:
return Response({'code': 400, 'msg': msg})
return Response({'code': 0, 'msg': '已保存', 'data': data})
return Response({'code': 400, 'msg': '无效 action'})
class FundFreezeLedgerView(APIView):
"""
POST /jituan/houtai/fund-freeze-ledger
action: list | events | unfreeze
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
kefu, permissions, err = _auth(request, PERM_LED)
if err:
return err
action = (request.data.get('action') or 'list').strip().lower()
if action == 'list':
return Response({'code': 0, 'msg': 'ok', 'data': admin_svc.list_ledgers(request, request.data)})
if action == 'events':
from jituan.services.fund_freeze_admin import _club
try:
lid = int(request.data.get('ledger_id') or 0)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': 'ledger_id 无效'})
return Response({
'code': 0,
'msg': 'ok',
'data': {'list': admin_svc.list_unfreeze_events(lid, _club(request))},
})
if action == 'unfreeze':
operator = getattr(getattr(request, 'user', None), 'UserUID', '') or ''
data, msg = admin_svc.manual_unfreeze(request, request.data, operator=operator)
if msg:
return Response({'code': 400, 'msg': msg})
return Response({'code': 0, 'msg': '解冻成功', 'data': data})
return Response({'code': 400, 'msg': '无效 action'})
class FundFreezeRiskView(APIView):
"""
POST /jituan/houtai/fund-freeze-risk
action: list
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
kefu, permissions, err = _auth(request, PERM_RISK)
if err:
return err
action = (request.data.get('action') or 'list').strip().lower()
if action == 'list':
return Response({'code': 0, 'msg': 'ok', 'data': admin_svc.list_risk_users(request, request.data)})
return Response({'code': 400, 'msg': '无效 action'})