From 3194a47f520ef4da39e7ab8121be6ce103f0792a Mon Sep 17 00:00:00 2001 From: XingQue Date: Sun, 26 Jul 2026 18:55:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B5=84=E9=87=91=E5=86=BB=E7=BB=93=20?= =?UTF-8?q?P2=20=E5=AE=A2=E6=9C=8D=E5=90=8E=E5=8F=B0=20API=EF=BC=88?= =?UTF-8?q?=E9=85=8D=E7=BD=AE/=E6=98=8E=E7=BB=86/=E9=A3=8E=E9=99=A9/?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E8=A7=A3=E5=86=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/views/kefu.py | 3 + jituan/services/fund_freeze_admin.py | 384 +++++++++++++++++++++++++++ jituan/urls.py | 8 + jituan/views_fund_freeze.py | 105 ++++++++ 4 files changed, 500 insertions(+) create mode 100644 jituan/services/fund_freeze_admin.py create mode 100644 jituan/views_fund_freeze.py diff --git a/backend/views/kefu.py b/backend/views/kefu.py index e758ca9..1b7c1b8 100644 --- a/backend/views/kefu.py +++ b/backend/views/kefu.py @@ -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, diff --git a/jituan/services/fund_freeze_admin.py b/jituan/services/fund_freeze_admin.py new file mode 100644 index 0000000..e95cb82 --- /dev/null +++ b/jituan/services/fund_freeze_admin.py @@ -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, + } diff --git a/jituan/urls.py b/jituan/urls.py index a29cbe8..9798e09 100644 --- a/jituan/urls.py +++ b/jituan/urls.py @@ -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'), diff --git a/jituan/views_fund_freeze.py b/jituan/views_fund_freeze.py new file mode 100644 index 0000000..8bc1bd6 --- /dev/null +++ b/jituan/views_fund_freeze.py @@ -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'})