276 lines
8.9 KiB
Python
276 lines
8.9 KiB
Python
"""资金冻结入账网关(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
|