""" 提现余额冻结服务 — 手动/自动打款申请共用。 规则摘要: - 比例模式:按当前余额切出 freeze_jine 入池,申请金额 apply_jine 为实际提现扣款部分(另扣手续费)。 - 固定模式:冻结池未满则优先填池;申请+池仍不足目标则整笔入池(freeze_only)。 - 驳回:仅退 shenqing_jine 到余额,冻结池不回滚。 """ from __future__ import annotations import decimal import logging from dataclasses import dataclass from typing import Optional, Tuple from django.db import transaction from peizhi.models import WithdrawConfig from .models import ( TixianDongjieGonggongPeizhi, TixianDongjieJilu, UserDashou, UserGuanshi, UserMain, UserShangjia, ) logger = logging.getLogger('yonghu.freeze') DONGJIE_MODE_RATIO = 1 DONGJIE_MODE_FIXED = 2 # 支持冻结的提现类型 FREEZE_LEIXING_SET = frozenset({1, 2, 5, 6}) LEIXING_META = { 1: { 'model': UserDashou, 'balance': 'yue', 'pool': 'dongjie_chi', 'solo_enabled': 'dandu_dongjie_enabled', 'mode': 'dongjie_mode', 'bili': 'dongjie_bili', 'guding': 'dongjie_guding_jine', 'role_name': '打手佣金', }, 5: { 'model': UserDashou, 'balance': 'yajin', 'pool': 'dongjie_chi_yajin', 'solo_enabled': 'dandu_dongjie_yajin_enabled', 'mode': 'dongjie_yajin_mode', 'bili': 'dongjie_yajin_bili', 'guding': 'dongjie_yajin_guding_jine', 'role_name': '打手押金', }, 2: { 'model': UserGuanshi, 'balance': 'yue', 'pool': 'dongjie_chi', 'solo_enabled': 'dandu_dongjie_enabled', 'mode': 'dongjie_mode', 'bili': 'dongjie_bili', 'guding': 'dongjie_guding_jine', 'role_name': '管事分红', }, 6: { 'model': UserShangjia, 'balance': 'yue', 'pool': 'dongjie_chi', 'solo_enabled': 'dandu_dongjie_enabled', 'mode': 'dongjie_mode', 'bili': 'dongjie_bili', 'guding': 'dongjie_guding_jine', 'role_name': '商家余额', }, } @dataclass class FreezeConfig: mode: int bili: decimal.Decimal guding_jine: decimal.Decimal source: str # 'user' | 'public' @dataclass class FreezeSplitResult: withdraw_jine: decimal.Decimal freeze_jine: decimal.Decimal freeze_only: bool message: str = '' @dataclass class ApplyFreezeOutcome: freeze_only: bool withdraw_jine: decimal.Decimal benchi_dongjie_jine: decimal.Decimal message: str = '' pool_after: decimal.Decimal = decimal.Decimal('0') balance_after: decimal.Decimal = decimal.Decimal('0') def _q(value) -> decimal.Decimal: return decimal.Decimal(str(value or 0)).quantize( decimal.Decimal('0.01'), rounding=decimal.ROUND_HALF_UP, ) def is_freeze_leixing(leixing: int) -> bool: return int(leixing) in FREEZE_LEIXING_SET def get_leixing_meta(leixing: int) -> dict: meta = LEIXING_META.get(int(leixing)) if not meta: raise ValueError(f'不支持的冻结提现类型: {leixing}') return meta def _build_config(mode, bili, guding, source) -> FreezeConfig: return FreezeConfig( mode=int(mode or DONGJIE_MODE_RATIO), bili=_q(bili), guding_jine=_q(guding), source=source, ) def resolve_freeze_config(profile, leixing: int) -> Optional[FreezeConfig]: """返回生效冻结配置;None 表示本笔不冻结。""" leixing = int(leixing) if leixing not in FREEZE_LEIXING_SET: return None meta = get_leixing_meta(leixing) if getattr(profile, meta['solo_enabled'], False): return _build_config( getattr(profile, meta['mode']), getattr(profile, meta['bili']), getattr(profile, meta['guding']), 'user', ) wc = WithdrawConfig.objects.filter(id=1).only('dongjie_quanju_enabled').first() if not wc or not wc.dongjie_quanju_enabled: return None pub = TixianDongjieGonggongPeizhi.objects.filter(leixing=leixing).first() if not pub or not pub.role_enabled: return None return _build_config(pub.dongjie_mode, pub.dongjie_bili, pub.dongjie_guding_jine, 'public') def compute_freeze_split( balance: decimal.Decimal, pool: decimal.Decimal, config: Optional[FreezeConfig], apply_jine: decimal.Decimal, ) -> FreezeSplitResult: """纯计算:不修改数据库。""" balance = _q(balance) pool = _q(pool) apply_jine = _q(apply_jine) if apply_jine <= 0: raise ValueError('提现金额必须大于0') if apply_jine > balance: raise ValueError(f'余额不足,当前余额: {balance}') if config is None: return FreezeSplitResult(apply_jine, decimal.Decimal('0'), False) if config.mode == DONGJIE_MODE_RATIO: if config.bili <= 0: return FreezeSplitResult(apply_jine, decimal.Decimal('0'), False) if config.bili >= 1: return FreezeSplitResult( decimal.Decimal('0'), apply_jine, True, message=f'已冻结 {apply_jine} 元进入资金池(比例100%)', ) freeze_jine = _q(balance * config.bili) max_withdraw = balance - freeze_jine if apply_jine > max_withdraw: raise ValueError( f'最多可提现 {max_withdraw} 元(需冻结 {freeze_jine} 元,占余额 {config.bili * 100:.2f}%)' ) return FreezeSplitResult(apply_jine, freeze_jine, False) if config.mode == DONGJIE_MODE_FIXED: target = _q(config.guding_jine) if target <= 0: return FreezeSplitResult(apply_jine, decimal.Decimal('0'), False) if pool >= target: return FreezeSplitResult(apply_jine, decimal.Decimal('0'), False) if apply_jine + pool < target: return FreezeSplitResult( decimal.Decimal('0'), apply_jine, True, message=f'已冻结 {apply_jine} 元,冻结池 {pool + apply_jine}/{target} 元,满额后可正常提现', ) gap = target - pool withdraw_jine = apply_jine - gap if withdraw_jine <= 0: return FreezeSplitResult( decimal.Decimal('0'), apply_jine, True, message=f'已冻结 {apply_jine} 元,冻结池 {pool + apply_jine}/{target} 元', ) return FreezeSplitResult(withdraw_jine, gap, False) return FreezeSplitResult(apply_jine, decimal.Decimal('0'), False) def get_locked_profile(user_main: UserMain, leixing: int): meta = get_leixing_meta(leixing) Model = meta['model'] try: return Model.objects.select_for_update().get(user=user_main), meta except Model.DoesNotExist: raise ValueError(f'用户{user_main.yonghuid}缺少{meta["role_name"]}身份账户') def _write_freeze_log(yonghuid, leixing, jine, pool_before, pool_after, caozuo, operator_id='', beizhu=''): TixianDongjieJilu.objects.create( yonghuid=yonghuid, leixing=leixing, jine=_q(jine), dongjie_chi_before=_q(pool_before), dongjie_chi_after=_q(pool_after), caozuo=caozuo, operator_id=operator_id or '', beizhu=beizhu or '', ) def apply_balance_freeze(user_main: UserMain, leixing: int, apply_jine) -> ApplyFreezeOutcome: """ 提现申请前执行冻结拆分(须在 transaction.atomic 内调用)。 - freeze_only=True:仅入池,不进入提现扣款流程 - 否则返回 withdraw_jine(实际提现扣款额)与 benchi_dongjie_jine """ leixing = int(leixing) apply_jine = _q(apply_jine) if not is_freeze_leixing(leixing): return ApplyFreezeOutcome(False, apply_jine, decimal.Decimal('0')) profile, meta = get_locked_profile(user_main, leixing) balance = _q(getattr(profile, meta['balance'])) pool = _q(getattr(profile, meta['pool'])) config = resolve_freeze_config(profile, leixing) split = compute_freeze_split(balance, pool, config, apply_jine) # 仅将冻结部分划入冻结池;实际提现扣款由 deduct_balance 完成,避免重复扣款 freeze_deduct = split.freeze_jine if freeze_deduct <= 0: if split.freeze_only: freeze_deduct = apply_jine else: return ApplyFreezeOutcome( freeze_only=False, withdraw_jine=split.withdraw_jine, benchi_dongjie_jine=decimal.Decimal('0'), pool_after=pool, balance_after=balance, ) if freeze_deduct > balance: raise ValueError('余额不足,无法完成冻结拆分') if not split.freeze_only and freeze_deduct + split.withdraw_jine > balance: raise ValueError('余额不足,无法完成冻结与提现拆分') new_balance = balance - freeze_deduct new_pool = pool + freeze_deduct setattr(profile, meta['balance'], new_balance) setattr(profile, meta['pool'], new_pool) update_fields = [meta['balance'], meta['pool']] if hasattr(profile, 'update_time'): update_fields.append('update_time') profile.save(update_fields=update_fields) if split.freeze_jine > 0: _write_freeze_log( user_main.yonghuid, leixing, freeze_deduct, pool, new_pool, TixianDongjieJilu.CAOZUO_APPLY, ) logger.info( '提现申请冻结 yonghuid=%s leixing=%s apply=%s freeze=%s withdraw=%s pool=%s balance=%s', user_main.yonghuid, leixing, apply_jine, freeze_deduct, split.withdraw_jine, new_pool, new_balance, ) if split.freeze_only: return ApplyFreezeOutcome( freeze_only=True, withdraw_jine=decimal.Decimal('0'), benchi_dongjie_jine=freeze_deduct, message=split.message or f'已冻结 {freeze_deduct} 元进入资金池', pool_after=new_pool, balance_after=new_balance, ) return ApplyFreezeOutcome( freeze_only=False, withdraw_jine=split.withdraw_jine, benchi_dongjie_jine=freeze_deduct, pool_after=new_pool, balance_after=new_balance, ) def admin_unfreeze_balance(user_main: UserMain, leixing: int, jine, operator_id='', beizhu='') -> decimal.Decimal: """管理员解冻:冻结池 → 可提现余额。""" leixing = int(leixing) jine = _q(jine) if jine <= 0: raise ValueError('解冻金额必须大于0') profile, meta = get_locked_profile(user_main, leixing) pool = _q(getattr(profile, meta['pool'])) if jine > pool: raise ValueError(f'解冻金额不能超过冻结池余额 {pool}') balance = _q(getattr(profile, meta['balance'])) new_pool = pool - jine new_balance = balance + jine setattr(profile, meta['pool'], new_pool) setattr(profile, meta['balance'], new_balance) update_fields = [meta['balance'], meta['pool']] if hasattr(profile, 'update_time'): update_fields.append('update_time') profile.save(update_fields=update_fields) _write_freeze_log( user_main.yonghuid, leixing, jine, pool, new_pool, TixianDongjieJilu.CAOZUO_UNFREEZE, operator_id=operator_id, beizhu=beizhu, ) return new_balance def get_user_freeze_info(user_main: UserMain, leixing: int) -> dict: """后台/详情页展示用。""" leixing = int(leixing) meta = get_leixing_meta(leixing) Model = meta['model'] try: profile = Model.objects.get(user=user_main) except Model.DoesNotExist: return {'exists': False} balance = _q(getattr(profile, meta['balance'])) pool = _q(getattr(profile, meta['pool'])) config = resolve_freeze_config(profile, leixing) max_apply = balance if config and config.mode == DONGJIE_MODE_RATIO and config.bili > 0 and config.bili < 1: freeze_part = _q(balance * config.bili) max_apply = balance - freeze_part return { 'exists': True, 'leixing': leixing, 'role_name': meta['role_name'], 'balance': str(balance), 'dongjie_chi': str(pool), 'dandu_dongjie_enabled': bool(getattr(profile, meta['solo_enabled'], False)), 'dongjie_mode': int(getattr(profile, meta['mode'], DONGJIE_MODE_RATIO)), 'dongjie_bili': str(_q(getattr(profile, meta['bili'], 0))), 'dongjie_guding_jine': str(_q(getattr(profile, meta['guding'], 0))), 'max_apply_jine': str(max_apply), 'freeze_active': config is not None, 'freeze_source': config.source if config else '', } def get_public_freeze_settings() -> dict: wc = WithdrawConfig.objects.filter(id=1).first() quanju = bool(wc.dongjie_quanju_enabled) if wc else False roles = [] for leixing in (1, 2, 5, 6): pub = TixianDongjieGonggongPeizhi.objects.filter(leixing=leixing).first() meta = get_leixing_meta(leixing) roles.append({ 'leixing': leixing, 'role_name': meta['role_name'], 'role_enabled': bool(pub.role_enabled) if pub else False, 'dongjie_mode': int(pub.dongjie_mode) if pub else DONGJIE_MODE_RATIO, 'dongjie_bili': str(_q(pub.dongjie_bili)) if pub else '0.00', 'dongjie_guding_jine': str(_q(pub.dongjie_guding_jine)) if pub else '0.00', }) return {'dongjie_quanju_enabled': quanju, 'dongjie_roles': roles} def save_public_freeze_settings(quanju_enabled: bool, roles: list) -> None: WithdrawConfig.objects.update_or_create(id=1, defaults={'dongjie_quanju_enabled': bool(quanju_enabled)}) for row in roles: leixing = int(row['leixing']) if leixing not in FREEZE_LEIXING_SET: continue TixianDongjieGonggongPeizhi.objects.update_or_create( leixing=leixing, defaults={ 'role_enabled': bool(row.get('role_enabled', False)), 'dongjie_mode': int(row.get('dongjie_mode', DONGJIE_MODE_RATIO)), 'dongjie_bili': _q(row.get('dongjie_bili', 0)), 'dongjie_guding_jine': _q(row.get('dongjie_guding_jine', 0)), }, ) def save_user_freeze_settings(user_main: UserMain, leixing: int, data: dict) -> dict: leixing = int(leixing) profile, meta = get_locked_profile(user_main, leixing) if 'dandu_dongjie_enabled' in data: setattr(profile, meta['solo_enabled'], bool(data['dandu_dongjie_enabled'])) if 'dongjie_mode' in data: setattr(profile, meta['mode'], int(data['dongjie_mode'])) if 'dongjie_bili' in data: setattr(profile, meta['bili'], _q(data['dongjie_bili'])) if 'dongjie_guding_jine' in data: setattr(profile, meta['guding'], _q(data['dongjie_guding_jine'])) update_fields = [] for key in (meta['solo_enabled'], meta['mode'], meta['bili'], meta['guding']): if key in [f.name for f in profile._meta.fields]: update_fields.append(key) if hasattr(profile, 'update_time'): update_fields.append('update_time') profile.save(update_fields=update_fields) return get_user_freeze_info(user_main, leixing)