Files
Django/jituan/services/fund_freeze.py
XingQue 6c96eb3bcb fix: 冻结条件可评估+分期规范化,定时扫描更及时
后台条件真正按罚款率/单量等评估;选项式配置所需字段规范化;Celery 改为每5分钟;小程序说明文案接口补齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 19:36:10 +08:00

645 lines
22 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,
'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),
}