435 lines
15 KiB
Python
435 lines
15 KiB
Python
"""
|
||
提现余额冻结服务 — 手动/自动打款申请共用。
|
||
|
||
规则摘要:
|
||
- 比例模式:按当前余额切出 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)
|