"""资金冻结入账网关与解冻骨架。 默认:无配置或 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, 'display_reason': (cfg.remark or '')[:255], 'remark': (cfg.remark or '')[:255], } 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 (getattr(cfg, 'remark', None) if cfg else None) 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]: """ 执行一笔冻结单的当前分期步。 force=True:后台手动,跳过时间/条件门槛。 条件未达标时不推进步数、不改 next_eligible_at,供定时任务下次重试。 """ from django.db import transaction from users.models import UserDashou, UserGuanshi, UserZuzhang, UserShenheguan from jituan.models import FundUnfreezeEvent from jituan.services.fund_freeze_conditions import evaluate_unfreeze_conditions 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() snap = ledger.config_snapshot or {} trigger_mode = (snap.get('unfreeze_trigger') or 'time_only') min_days = max(0, int(snap.get('freeze_min_days') or 0)) frozen_at = ledger.frozen_at or now min_days_ok = force or (now >= frozen_at + timedelta(days=min_days)) time_ok = force or ( not ledger.next_eligible_at or ledger.next_eligible_at <= now ) if trigger_mode == 'manual_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, 'need_condition': False} else: step = steps[step_idx] need_step_cond = bool(step.get('need_condition')) cond_ok = True cond_snap: Dict[str, Any] = {} need_eval = ( not force and ( need_step_cond or trigger_mode in ( 'condition_only', 'time_and_condition', 'time_or_condition', ) ) ) 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': '角色扩展表不存在'} if need_eval: cond_ok, cond_snap = evaluate_unfreeze_conditions( ledger.club_id, ledger.user_id, ledger.role, snap.get('condition_json') or {}, profile=profile, ) if not force: if trigger_mode == 'time_only': if not time_ok: return {'ok': False, 'msg': '未到解冻时间', 'next_eligible_at': ledger.next_eligible_at} elif trigger_mode == 'condition_only': if not min_days_ok: return {'ok': False, 'msg': f'未满最短冻结天数({min_days}天)'} if not cond_ok: return { 'ok': False, 'msg': '条件未达标', 'condition': cond_snap, 'retry': True, } elif trigger_mode == 'time_and_condition': if not time_ok: return {'ok': False, 'msg': '未到解冻时间', 'next_eligible_at': ledger.next_eligible_at} if not cond_ok: return { 'ok': False, 'msg': '已到期但条件未达标,等待达标后自动解', 'condition': cond_snap, 'retry': True, } elif trigger_mode == 'time_or_condition': if not (time_ok or cond_ok): return { 'ok': False, 'msg': '未到期且条件未达标', 'condition': cond_snap, 'retry': True, } # 分期单步要求达标时,即使全局是 time_only / time_or 已到期,也仍需条件 if need_step_cond and not cond_ok: return { 'ok': False, 'msg': '本分期步要求条件达标', 'condition': cond_snap, 'retry': True, } 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} # 冻结池不足则按实际可解 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) ledger.next_eligible_at = frozen_at + timedelta(days=days) else: # 无更多配置步:剩余立即进入可扫状态,下一步按 remain 吃完 ledger.next_eligible_at = now ledger.save(update_fields=[ 'amount_unfrozen', 'status', 'current_step', 'next_eligible_at', 'UpdateTime', ]) event_trigger = trigger if not force and trigger_mode in ('condition_only', 'time_or_condition') and cond_ok and not time_ok: event_trigger = 'condition' 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=event_trigger, reason=(reason or f'{event_trigger}解冻')[:255], operator_id=(operator_id or '')[:32], condition_snapshot=cond_snap or {}, ) return { 'ok': True, 'amount': amount, 'remain': max(remain, Decimal('0.00')), 'done': remain <= 0, 'ledger_id': ledger.id, 'condition': cond_snap, } def scan_due_freeze_ledgers(limit: int = 200) -> Dict[str, Any]: """ 定时任务入口。 1) 到期(next_eligible_at<=now)的冻结中/部分解冻单 2) 条件触发类:condition_only / time_or_condition —— 即使未到期也扫描,避免「条件先达标却要干等到期」 临界:条件未达标只记 fail+retry,不改账本;下次 beat 再试。 """ from django.db.models import Q now = timezone.now() base = FundFreezeLedger.query.filter( status__in=[FundFreezeLedger.STATUS_FROZEN, FundFreezeLedger.STATUS_PARTIAL], ) due_ids = list( base.filter(next_eligible_at__lte=now) .order_by('next_eligible_at', 'id') .values_list('id', flat=True)[:limit] ) # JSON 字段路径:MySQL/Postgres Django JSONField 支持 key 查询 cond_ids = [] remain = max(0, limit - len(due_ids)) if remain > 0: try: cond_ids = list( base.filter( Q(config_snapshot__unfreeze_trigger='condition_only') | Q(config_snapshot__unfreeze_trigger='time_or_condition') ) .exclude(id__in=due_ids) .order_by('id') .values_list('id', flat=True)[:remain] ) except Exception as e: logger.warning('scan condition branch skipped: %s', e) ids = due_ids + cond_ids ok_n = fail_n = retry_n = 0 for lid in ids: try: r = apply_unfreeze_step(lid, trigger='schedule') if r.get('ok'): ok_n += 1 else: fail_n += 1 if r.get('retry'): retry_n += 1 logger.info('unfreeze skip id=%s msg=%s', lid, r.get('msg')) except Exception as e: fail_n += 1 logger.exception('unfreeze error id=%s: %s', lid, e) return { 'ok': ok_n, 'fail': fail_n, 'retry': retry_n, 'scanned': len(ids), 'due': len(due_ids), 'cond_scan': len(cond_ids), }