Files
Django/jituan/services/fund_freeze.py

276 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""资金冻结入账网关P0
默认:无配置或 enabled=False → 全额进可提现,与现网一致,不写流水。
开启后:拆「可提现 + 冻结池」并写 FundFreezeLedger。
注意P0 仅提供网关;结算调用点在 P1 收口,避免一次改太多接口。
"""
from __future__ import annotations
import logging
from datetime import timedelta
from decimal import Decimal, ROUND_HALF_UP
from typing import Any, Dict, Optional, Tuple
from django.core.cache import cache
from django.db.models import F
from django.utils import timezone
from jituan.constants import CLUB_ID_DEFAULT
from jituan.models import ClubFundFreezeConfig, FundFreezeLedger
logger = logging.getLogger(__name__)
ROLE_DASHOU = 'dashou'
ROLE_GUANSHI = 'guanshi'
ROLE_ZUZHANG = 'zuzhang'
ROLE_SHENHEGUAN = 'shenheguan'
SOURCE_ORDER_SETTLE = 'order_settle'
SOURCE_FENHONG = 'fenhong'
SOURCE_KAOHE_FEE = 'kaohe_fee'
_CFG_CACHE_TTL = 60
def _money(v) -> Decimal:
return Decimal(str(v or 0)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
def _club(club_id: Optional[str]) -> str:
return (club_id or '').strip() or CLUB_ID_DEFAULT
def get_freeze_config(club_id: Optional[str], role: str) -> Optional[ClubFundFreezeConfig]:
cid = _club(club_id)
role = (role or '').strip().lower()
key = f'fund_freeze_cfg:{cid}:{role}'
cached = cache.get(key)
if cached is not None:
return cached or None
row = ClubFundFreezeConfig.query.filter(club_id=cid, role=role).first()
cache.set(key, row or False, _CFG_CACHE_TTL)
return row
def invalidate_freeze_config_cache(club_id: Optional[str] = None, role: Optional[str] = None):
if club_id and role:
cache.delete(f'fund_freeze_cfg:{_club(club_id)}:{(role or "").strip().lower()}')
return
# 粗粒度:调用方改配置后建议带 club+role 清;此处留空避免误扫
def calc_freeze_amount(credit: Decimal, cfg: ClubFundFreezeConfig) -> Decimal:
credit = _money(credit)
if credit <= 0:
return Decimal('0.00')
min_c = _money(cfg.min_credit_to_freeze)
if min_c > 0 and credit < min_c:
return Decimal('0.00')
mode = int(cfg.freeze_mode or 1)
frozen = Decimal('0.00')
if mode == 2:
frozen = _money(cfg.freeze_fixed)
elif mode == 3:
frozen = _money(credit * Decimal(str(cfg.freeze_rate or 0)))
cap = _money(cfg.freeze_cap)
if cap > 0:
frozen = min(frozen, cap)
else:
frozen = _money(credit * Decimal(str(cfg.freeze_rate or 0)))
cap = _money(cfg.freeze_cap)
if cap > 0:
frozen = min(frozen, cap)
if frozen < 0:
frozen = Decimal('0.00')
if frozen > credit:
frozen = credit
return frozen
def _source_allowed(cfg: ClubFundFreezeConfig, source_type: str) -> bool:
scope = cfg.source_scope
if not scope:
return True
if isinstance(scope, str):
return source_type == scope
try:
return source_type in list(scope)
except TypeError:
return True
def _config_snapshot(cfg: ClubFundFreezeConfig) -> Dict[str, Any]:
return {
'club_id': cfg.club_id,
'role': cfg.role,
'enabled': cfg.enabled,
'freeze_mode': cfg.freeze_mode,
'freeze_rate': str(cfg.freeze_rate),
'freeze_fixed': str(cfg.freeze_fixed),
'freeze_cap': str(cfg.freeze_cap),
'min_credit_to_freeze': str(cfg.min_credit_to_freeze),
'source_scope': cfg.source_scope,
'unfreeze_trigger': cfg.unfreeze_trigger,
'schedule_mode': cfg.schedule_mode,
'installment_json': cfg.installment_json,
'condition_json': cfg.condition_json,
'freeze_min_days': cfg.freeze_min_days,
}
def _first_step_delay_days(cfg: ClubFundFreezeConfig) -> int:
if (cfg.schedule_mode or '') == 'installment':
steps = (cfg.installment_json or {}).get('steps') or []
if steps:
try:
return max(0, int(steps[0].get('after_days') or 0))
except (TypeError, ValueError):
pass
return max(0, int(cfg.freeze_min_days or 0))
def _add_withdrawable(role: str, profile, available: Decimal, also_zonge: bool = False):
"""原子增加可提现;组长字段为 ketixian_jine。"""
available = _money(available)
if available == 0:
return
if role == ROLE_ZUZHANG:
type(profile).query.filter(pk=profile.pk).update(
ketixian_jine=F('ketixian_jine') + available,
)
else:
updates = {'yue': F('yue') + available}
if also_zonge and hasattr(profile, 'zonge'):
updates['zonge'] = F('zonge') + available
type(profile).query.filter(pk=profile.pk).update(**updates)
def _add_dongjie(profile, frozen: Decimal):
frozen = _money(frozen)
if frozen == 0:
return
type(profile).query.filter(pk=profile.pk).update(
dongjie_yue=F('dongjie_yue') + frozen,
)
def credit_role_balance(
*,
club_id: Optional[str],
user_id: str,
role: str,
amount,
source_type: str,
biz_id: str,
profile=None,
order_id: str = '',
czjilu_id: str = '',
freeze_reason: str = '',
also_credit_zonge: bool = False,
) -> Dict[str, Any]:
"""
统一入账。返回 {available, frozen, ledger_id, skipped_freeze}。
调用方须在外层 transaction.atomic() 内,并已持有/即将更新的业务一致性。
profile: 已加载的角色扩展实例UserDashou 等);若空则按 role+user_id 查。
"""
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'
if credit <= 0:
return {'available': Decimal('0.00'), 'frozen': Decimal('0.00'), 'ledger_id': None, 'skipped_freeze': True}
if profile is None:
profile = _load_profile(role, user_id)
if profile is None:
raise ValueError(f'找不到角色资料 role={role} user_id={user_id}')
cfg = get_freeze_config(cid, role)
freeze_on = bool(
cfg and cfg.enabled and _source_allowed(cfg, source_type) and biz_id
)
frozen = calc_freeze_amount(credit, cfg) if freeze_on else Decimal('0.00')
available = _money(credit - frozen)
# 幂等:已存在同 biz 冻结单则只补可提现差额不重复冻(极端重入)
if freeze_on and frozen > 0:
existed = FundFreezeLedger.query.filter(
club_id=cid, role=role, source_type=source_type, biz_id=biz_id,
).first()
if existed:
logger.warning(
'fund_freeze idempotent hit club=%s role=%s source=%s biz=%s — 不再入账',
cid, role, source_type, biz_id,
)
return {
'available': Decimal('0.00'),
'frozen': Decimal('0.00'),
'ledger_id': existed.id,
'skipped_freeze': True,
'idempotent': True,
}
_add_withdrawable(role, profile, available, also_zonge=also_credit_zonge)
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()
ledger = FundFreezeLedger.query.create(
club_id=cid,
user_id=str(user_id),
role=role,
amount_total=frozen,
amount_unfrozen=Decimal('0.00'),
status=FundFreezeLedger.STATUS_FROZEN,
source_type=source_type,
order_id=(order_id or '')[:64],
czjilu_id=(czjilu_id or '')[:64],
biz_id=biz_id[:64],
freeze_reason=(freeze_reason or f'{source_type}冻结')[:255],
config_snapshot=_config_snapshot(cfg),
current_step=1,
next_eligible_at=next_at,
)
ledger_id = ledger.id
return {
'available': available,
'frozen': frozen,
'ledger_id': ledger_id,
'skipped_freeze': frozen <= 0,
}
def _load_profile(role: str, user_id: str):
from users.models import User, UserDashou, UserGuanshi, UserZuzhang, UserShenheguan
user = User.query.filter(UserUID=user_id).first()
if not user:
return None
mapping = {
ROLE_DASHOU: UserDashou,
ROLE_GUANSHI: UserGuanshi,
ROLE_ZUZHANG: UserZuzhang,
ROLE_SHENHEGUAN: UserShenheguan,
}
model = mapping.get(role)
if not model:
return None
return model.query.filter(user=user).first()
def split_preview(club_id: Optional[str], role: str, amount, source_type: str = SOURCE_ORDER_SETTLE) -> Tuple[Decimal, Decimal]:
"""只算不写库:后台试算用。"""
credit = _money(amount)
cfg = get_freeze_config(club_id, role)
if not cfg or not cfg.enabled or not _source_allowed(cfg, source_type):
return credit, Decimal('0.00')
frozen = calc_freeze_amount(credit, cfg)
return _money(credit - frozen), frozen