Files
Django/jituan/services/fund_freeze.py

533 lines
18 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.
"""资金冻结入账网关与解冻骨架。
默认:无配置或 enabled=False → 全额进可提现,与现网一致,不写流水。
开启后:拆「可提现 + 冻结池」并写 FundFreezeLedger。
P1打手订单结算入口已统一走 credit_dashou_order_settle未开冻结时行为与改造前一致
"""
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
def order_club_id(order) -> str:
return _club(getattr(order, 'ClubID', None) or getattr(order, 'club_id', None))
def credit_dashou_order_settle(
*,
order,
dashou_profile,
user_id: str,
amount,
set_idle_status: bool = False,
increment_chengjiao: bool = True,
) -> Dict[str, Any]:
"""
打手订单结算专用入账P1
- 可提现走 credit_role_balance未开冻结=全额进 yue与改造前一致
- zonge / 今日今月收益仍按「全额」累加(统计口径不变)
- 成交单量 +1、可选置空闲状态
调用方须已在 transaction.atomic() 内;本函数用 F() 更新,勿再对同一 profile save 覆盖余额字段。
"""
from users.models import UserDashou
credit = _money(amount)
order_id = str(getattr(order, 'OrderID', None) or getattr(order, 'order_id', '') or '')
cid = order_club_id(order)
if credit <= 0:
updates = {}
if increment_chengjiao:
updates['chengjiaozongliang'] = F('chengjiaozongliang') + 1
if set_idle_status:
updates['zhuangtai'] = 1
if updates:
UserDashou.query.filter(pk=dashou_profile.pk).update(**updates)
return {
'available': Decimal('0.00'),
'frozen': Decimal('0.00'),
'ledger_id': None,
'skipped_freeze': True,
}
result = credit_role_balance(
club_id=cid,
user_id=str(user_id),
role=ROLE_DASHOU,
amount=credit,
source_type=SOURCE_ORDER_SETTLE,
biz_id=order_id,
profile=dashou_profile,
order_id=order_id,
freeze_reason=f'订单结算 {order_id}',
also_credit_zonge=False,
)
if result.get('idempotent'):
logger.warning('打手订单结算幂等跳过 order=%s user=%s', order_id, user_id)
return result
updates = {
'zonge': F('zonge') + credit,
'jinrishouyi': F('jinrishouyi') + credit,
'jinyueshouyi': F('jinyueshouyi') + credit,
}
if increment_chengjiao:
updates['chengjiaozongliang'] = F('chengjiaozongliang') + 1
if set_idle_status:
updates['zhuangtai'] = 1
UserDashou.query.filter(pk=dashou_profile.pk).update(**updates)
return result
def _step_unfreeze_amount(ledger: FundFreezeLedger, step: dict) -> Decimal:
remain = _money(ledger.amount_total - ledger.amount_unfrozen)
if remain <= 0:
return Decimal('0.00')
typ = (step.get('type') or 'remain').strip().lower()
if typ == 'remain':
return remain
if typ == 'fixed':
return min(remain, _money(step.get('value') or 0))
# ratio
ratio = Decimal(str(step.get('value') or 0))
if ratio <= 0:
return Decimal('0.00')
return min(remain, _money(ledger.amount_total * ratio))
def _plan_steps(snapshot: dict) -> list:
schedule = (snapshot or {}).get('schedule_mode') or 'all_at_once'
if schedule == 'installment':
steps = ((snapshot or {}).get('installment_json') or {}).get('steps') or []
if steps:
return sorted(steps, key=lambda s: int(s.get('seq') or 0))
# 一次全解:单步 remain天数用 freeze_min_days已在 next_eligible_at
return [{'seq': 1, 'after_days': 0, 'type': 'remain', 'value': 1, 'need_condition': False}]
def apply_unfreeze_step(
ledger_id: int,
*,
trigger: str = 'schedule',
operator_id: str = '',
reason: str = '',
force: bool = False,
) -> Dict[str, Any]:
"""
执行一笔冻结单的当前分期步(骨架:先支持到期/手动;条件解冻 P4 再接指标)。
force=True 时忽略 next_eligible_at后台手动
"""
from django.db import transaction
from users.models import UserDashou, UserGuanshi, UserZuzhang, UserShenheguan
from jituan.models import FundUnfreezeEvent
with transaction.atomic():
ledger = (
FundFreezeLedger.objects.select_for_update()
.filter(pk=ledger_id)
.first()
)
if not ledger:
return {'ok': False, 'msg': '冻结单不存在'}
if ledger.status in (FundFreezeLedger.STATUS_DONE, FundFreezeLedger.STATUS_VOID):
return {'ok': False, 'msg': '已完结或已作废'}
now = timezone.now()
if not force and ledger.next_eligible_at and ledger.next_eligible_at > now:
return {'ok': False, 'msg': '未到解冻时间', 'next_eligible_at': ledger.next_eligible_at}
snap = ledger.config_snapshot or {}
trigger_mode = (snap.get('unfreeze_trigger') or 'time_only')
if trigger_mode == 'manual_only' and not force:
return {'ok': False, 'msg': '仅允许手动解冻'}
# condition_*自动任务暂不评估指标P4手动 force 可解
if trigger_mode == 'condition_only' and not force:
return {'ok': False, 'msg': '条件解冻尚未启用自动评估'}
steps = _plan_steps(snap)
step_idx = max(0, int(ledger.current_step or 1) - 1)
if step_idx >= len(steps):
step = {'seq': ledger.current_step, 'type': 'remain', 'value': 1}
else:
step = steps[step_idx]
amount = _step_unfreeze_amount(ledger, step)
if amount <= 0:
ledger.status = FundFreezeLedger.STATUS_DONE
ledger.save(update_fields=['status', 'UpdateTime'])
return {'ok': True, 'amount': Decimal('0.00'), 'done': True}
profile_map = {
ROLE_DASHOU: UserDashou,
ROLE_GUANSHI: UserGuanshi,
ROLE_ZUZHANG: UserZuzhang,
ROLE_SHENHEGUAN: UserShenheguan,
}
model = profile_map.get(ledger.role)
if not model:
return {'ok': False, 'msg': f'未知角色 {ledger.role}'}
from users.models import User
user = User.query.filter(UserUID=ledger.user_id).first()
if not user:
return {'ok': False, 'msg': '用户不存在'}
profile = model.objects.select_for_update().filter(user=user).first()
if not profile:
return {'ok': False, 'msg': '角色扩展表不存在'}
# 冻结池不足则按实际可解
cur_frozen = _money(getattr(profile, 'dongjie_yue', 0) or 0)
if cur_frozen < amount:
amount = cur_frozen
if amount <= 0:
return {'ok': False, 'msg': '冻结池余额不足'}
if ledger.role == ROLE_ZUZHANG:
model.query.filter(pk=profile.pk).update(
dongjie_yue=F('dongjie_yue') - amount,
ketixian_jine=F('ketixian_jine') + amount,
)
else:
model.query.filter(pk=profile.pk).update(
dongjie_yue=F('dongjie_yue') - amount,
yue=F('yue') + amount,
)
new_unfrozen = _money(ledger.amount_unfrozen + amount)
remain = _money(ledger.amount_total - new_unfrozen)
ledger.amount_unfrozen = new_unfrozen
next_step = int(ledger.current_step or 1) + 1
if remain <= 0:
ledger.status = FundFreezeLedger.STATUS_DONE
ledger.current_step = next_step
ledger.next_eligible_at = None
else:
ledger.status = FundFreezeLedger.STATUS_PARTIAL
ledger.current_step = next_step
# 下一步到期时间
if next_step - 1 < len(steps):
nxt = steps[next_step - 1]
days = int(nxt.get('after_days') or 0)
# after_days 相对冻结日
ledger.next_eligible_at = ledger.frozen_at + timedelta(days=days)
else:
ledger.next_eligible_at = now
ledger.save(update_fields=[
'amount_unfrozen', 'status', 'current_step', 'next_eligible_at', 'UpdateTime',
])
FundUnfreezeEvent.query.create(
ledger_id=ledger.id,
club_id=ledger.club_id,
user_id=ledger.user_id,
role=ledger.role,
step_seq=int(step.get('seq') or ledger.current_step),
amount=amount,
trigger=trigger,
reason=(reason or f'{trigger}解冻')[:255],
operator_id=(operator_id or '')[:32],
condition_snapshot={},
)
return {
'ok': True,
'amount': amount,
'remain': max(remain, Decimal('0.00')),
'done': remain <= 0,
'ledger_id': ledger.id,
}
def scan_due_freeze_ledgers(limit: int = 200) -> Dict[str, Any]:
"""定时任务入口:扫到期冻结单并尝试解冻一步。"""
now = timezone.now()
qs = (
FundFreezeLedger.query.filter(
status__in=[FundFreezeLedger.STATUS_FROZEN, FundFreezeLedger.STATUS_PARTIAL],
next_eligible_at__lte=now,
)
.order_by('next_eligible_at', 'id')[:limit]
)
ok_n = fail_n = 0
for row in qs:
try:
r = apply_unfreeze_step(row.id, trigger='schedule')
if r.get('ok'):
ok_n += 1
else:
fail_n += 1
logger.info('unfreeze skip id=%s msg=%s', row.id, r.get('msg'))
except Exception as e:
fail_n += 1
logger.exception('unfreeze error id=%s: %s', row.id, e)
return {'ok': ok_n, 'fail': fail_n, 'scanned': ok_n + fail_n}