feat: 资金冻结 P1 打手结单收口网关 + 到期解冻定时任务骨架

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-26 18:49:44 +08:00
parent d40a27a5ca
commit 6a1895e566
9 changed files with 352 additions and 98 deletions

View File

@@ -1,9 +1,9 @@
"""资金冻结入账网关P0
"""资金冻结入账网关与解冻骨架
默认:无配置或 enabled=False → 全额进可提现,与现网一致,不写流水。
开启后:拆「可提现 + 冻结池」并写 FundFreezeLedger。
注意P0 仅提供网关;结算调用点在 P1 收口,避免一次改太多接口
P1打手订单结算入口已统一走 credit_dashou_order_settle未开冻结时行为与改造前一致
"""
from __future__ import annotations
@@ -273,3 +273,260 @@ def split_preview(club_id: Optional[str], role: str, amount, source_type: str =
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}