实现提现资金冻结:手动/自动打款共用冻结拆分,驳回仅退 shenqing_jine。

新增公共/用户冻结配置、流水表与后台解冻接口;修复冻结与扣款重复扣余额问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQueandCursor committed 2026-07-10 04:06:08 +08:00
1 parent 146a64b710
commit 8678f54459
13 files changed
+1447 -20

No files matched your search

+381
View File
@@ -0,0 +1,381 @@
# 资金冻结功能 — 设计方案(待确认)
> 本文档仅描述思路与边界,**确认后再写代码**。
> 目标:打手 / 管事 / 商家三类角色的可提现余额,在**自动打款申请**时按配置冻结一部分资金,进入「冻结资金池」,防止重复提现;后台可配置、可单独覆盖、可解冻。
---
## 一、需求理解(我的归纳)
| 要点 | 理解 |
|------|------|
| 冻结对象 | 打手佣金余额(`user_dashou.yue`)、管事余额(`user_guanshi.yue`)、商家余额(`user_shangjia.yue`) |
| 冻结资金去向 | 从「可提现余额」扣出 → 进入「冻结资金池」字段,**永远不能通过提现提出** |
| 配置层级 | 公共配置(提现设置页) + 用户单独配置(各角色详情页,开启后覆盖公共) |
| 冻结方式 | **比例**:按当前余额冻结 X%;**固定金额**:冻结池累计须达到 Y 元 |
| 生效范围(一期) | **仅自动打款申请** `POST /yonghu/zddksh`;收款 `tixiansq` 不再二次冻结 |
| 驳回退款 | 仅退回**本次实际进入提现流程的金额**;本次划入冻结池的金额一并回滚 |
| 后台解冻 | 管理员在角色详情页,可将冻结池部分/全部划回可提现余额 |
**一期不做(除非你确认要加):**
- 组长(`leixing=3`)、审核官(`leixing=4`)、打手押金(`leixing=5`) 的冻结
- 手动打款 `POST /yonghu/tixian` 的冻结(可二期复用同一套 `freeze_service`)
---
## 二、现有提现流程(自动打款)
```
用户 POST /yonghu/zddksh { leixing, jine }
↓
create_audit_application()
├─ validate_withdraw_eligibility() # 校验余额 >= jine
├─ calc_fee_amounts(jine) # 手续费、实际到账
├─ deduct_balance(leixing, jine) # 从余额扣 jine(申请扣款额)
└─ 写 TixianShenheJilu + Tixianjilu
shenqing_jine = jine
jine(记录) = shijidaozhang(到账额)
审核通过 → 状态 6 待收款
用户 POST /yonghu/tixiansq 收款 → 微信打款(不再动余额)
驳回 → refund_balance(shenqing_jine) 退回余额
```
**改造点:在 `deduct_balance` 之前插入「冻结拆分」逻辑。**
---
## 三、表模型设计
### 3.1 公共冻结配置表 `tixian_dongjie_gonggong_peizhi`
每个角色一行(`leixing` 唯一):
| 字段 | 类型 | 说明 |
|------|------|------|
| `leixing` | SmallInt | `1`打手佣金 `2`管事 `6`商家(唯一) |
| `role_enabled` | Boolean | 该角色是否启用公共冻结(默认 `False`) |
| `dongjie_mode` | SmallInt | `1`=按比例 `2`=固定金额 |
| `dongjie_bili` | Decimal(5,4) | 比例,如 `0.1000` = 10% |
| `dongjie_guding_jine` | Decimal(12,2) | 固定冻结目标金额 |
| `update_time` | DateTime | |
### 3.2 全局总开关(挂在现有 `withdraw_config` 表)
| 字段 | 说明 |
|------|------|
| `dongjie_quanju_enabled` | 全员冻结总开关,默认 `False`;**仅当为 True 时,公共配置才可能生效** |
> 生效条件(公共):`dongjie_quanju_enabled == True` **且** 该角色 `role_enabled == True`
### 3.3 三个扩展表新增字段(结构相同)
**`user_dashou` / `user_guanshi` / `user_shangjia`:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `dongjie_chi` | Decimal(12,2) | **冻结资金池**(累计,默认 0) |
| `dandu_dongjie_enabled` | Boolean | 是否启用单独冻结配置(默认 `False`) |
| `dongjie_mode` | SmallInt | `1`比例 / `2`固定(仅单独配置用) |
| `dongjie_bili` | Decimal(5,4) | 单独比例 |
| `dongjie_guding_jine` | Decimal(12,2) | 单独固定金额 |
> 配置优先级:
> `dandu_dongjie_enabled == True` → 用扩展表上的 mode/bili/guding
> 否则 → 用公共表(且需全局+角色开关均开)
> 否则 → **不冻结**
### 3.4 审核记录补充字段(驳回回滚用)
**`tixian_shenhe_jilu` 新增:**
| 字段 | 说明 |
|------|------|
| `benchi_dongjie_jine` | 本次申请划入冻结池的金额(驳回时须回滚) |
**`tixianjilu` 同步写入**(与审核单一致,便于对账)。
### 3.5 解冻流水表 `tixian_dongjie_jilu`(建议)
| 字段 | 说明 |
|------|------|
| `yonghuid`, `leixing`, `jine`, `dongjie_chi_before`, `dongjie_chi_after` | |
| `caozuo` | `1`申请冻结 `2`驳回回滚 `3`管理员解冻 |
| `operator_id`, `beizhu`, `create_time` | |
便于审计,避免资金纠纷。
---
## 四、配置解析规则
```python
def resolve_freeze_config(profile, leixing) -> FreezeConfig | None:
"""
返回生效的冻结配置;None 表示本笔不冻结。
"""
if profile.dandu_dongjie_enabled:
return FreezeConfig(mode=..., bili=..., guding=..., source='user')
global_on = WithdrawConfig.dongjie_quanju_enabled
if not global_on:
return None
pub = TixianDongjieGonggongPeizhi.objects.get(leixing=leixing)
if not pub.role_enabled:
return None
return FreezeConfig(mode=pub.dongjie_mode, ..., source='public')
```
**前端展示规则(详情页):**
- 选「按比例」→ 只展示比例输入框
- 选「固定金额」→ 只展示固定金额输入框
- 始终展示:`冻结资金池当前余额`、`是否启用单独配置` 开关
---
## 五、核心算法(自动打款申请时)
统一入口:`compute_freeze_split(balance, pool, config, apply_jine) -> FreezeResult`
所有金额 `Decimal`,保留 2 位,`ROUND_HALF_UP`。
### 5.1 无配置 / 未开启
```
freeze_jine = 0
withdraw_jine = apply_jine # 走原逻辑
```
### 5.2 按比例冻结(mode=1)
**语义:按申请时刻的「可提现余额」先切出冻结部分,剩余部分才能进入提现扣款。**
设:
- `B` = 当前可提现余额(申请前)
- `R` = 冻结比例(如 0.10)
- `A` = 用户申请提现金额 `jine`
**步骤:**
1. `freeze_from_balance = quantize(B * R)`
2. `max_withdraw = B - freeze_from_balance`
3. 若 `A > max_withdraw` → **拒绝**:`最多可提现 {max_withdraw} 元({R*100}% 已冻结)`
4. 若 `freeze_from_balance > 0`:
- `balance -= freeze_from_balance`
- `dongjie_chi += freeze_from_balance`
5. 对金额 `A` 走原流程:`deduct_balance(A)` → 手续费基于 `A` 计算
**示例(10%):**
| 余额 B | 申请 A | 划入冻结池 | 实际提现扣款 | 申请后余额 | 冻结池 |
|--------|--------|------------|--------------|------------|--------|
| 100 | 90 | 10 | 90 | 0 | 10 |
| 100 | 50 | 10 | 50 | 40 | 10 |
| 100 | 100 | — | 拒绝(最多 90) | — | — |
> 关键:冻结部分**一次性从余额切走**,避免「余额还剩 10% 又能再提」的漏洞。
### 5.3 按固定金额冻结(mode=2)
**语义:冻结池累计须达到目标 `T`;未满则优先用本次申请金额「填池」。**
设:
- `P` = 当前 `dongjie_chi`
- `T` = 配置的固定冻结目标
- `A` = 用户申请金额
- `B` = 当前余额(须 `A <= B`)
**步骤:**
1. 若 `P >= T` → **不冻结**,`withdraw_jine = A`,走原逻辑
2. 若 `P < T`:
- `gap = T - P`
- **情况 A**:`A + P < T`(本次申请填池后仍未达标)
- 整笔 `A` 划入冻结池,**不创建提现审核单**(或创建「仅冻结」记录,状态特殊)
- `balance -= A`,`dongjie_chi += A`
- 返回提示:`已冻结 {A} 元,冻结池 {P+A}/{T},满额后可正常提现`
- **情况 B**:`A + P >= T`(本次可填满池并有余款提现)
- `fill = gap`(填池金额)
- `withdraw_jine = A - fill`
- `balance -= A`(等价:先扣 A,其中 fill 入池、withdraw_jine 进入提现)
- `dongjie_chi += fill`
- 对 `withdraw_jine` 计算手续费、写审核单
**示例(T=100):**
| 池 P | 余额 B | 申请 A | 结果 |
|------|--------|--------|------|
| 30 | 50 | 50 | 50+30<100 → 全部入池,池=80,不提现 |
| 30 | 80 | 80 | 80+30≥100 → 填池70,提现10 |
| 100 | 50 | 50 | 池已满 → 正常提现50 |
### 5.4 手续费与记录字段(不弄乱)
仅对 **`withdraw_jine`(实际进入提现流程的金额)** 计算:
```
shouxufei = withdraw_jine * feilv
shijidaozhang = withdraw_jine - shouxufei
TixianShenheJilu.shenqing_jine = withdraw_jine # 申请扣款额
TixianShenheJilu.shijidaozhang = shijidaozhang # 到账额
TixianShenheJilu.benchi_dongjie_jine = freeze_jine # 本次入池
```
**收款阶段 `tixiansq`:** 只打 `shijidaozhang`,不再动余额/冻结池。
---
## 六、驳回 / 解冻
### 6.1 审核驳回(自动 + 手动均适用)
在现有 `refund_balance(shenqing_jine)` 基础上增加:
```
balance += shenqing_jine # 退回提现扣款
dongjie_chi -= benchi_dongjie_jine # 回滚本次冻结(若有)
balance += benchi_dongjie_jine # 冻结部分也回到可提现余额
```
须在同一 `transaction.atomic` + `select_for_update` 内完成。
### 6.2 管理员解冻
**新接口** `POST /houtai/.../dongjie_jiedong`(三个详情页共用):
```json
{
"yonghuid": "0000001",
"leixing": 1,
"jine": "50.00",
"beizhu": "人工解冻"
}
```
校验:
- `0 < jine <= dongjie_chi`
- `balance += jine`,`dongjie_chi -= jine`
- 写解冻流水
---
## 七、接口设计
### 7.1 公共配置(提现设置页)
| 方法 | 路径 | 权限 |
|------|------|------|
| GET | `/houtai/hthqtxpz` 扩展返回冻结配置 | 现有提现设置权限 |
| POST | `/houtai/htxgtxsz` 扩展保存冻结配置 | 同上 |
返回/保存结构示例:
```json
{
"dongjie_quanju_enabled": false,
"dongjie_roles": [
{
"leixing": 1,
"role_name": "打手佣金",
"role_enabled": false,
"dongjie_mode": 1,
"dongjie_bili": "0.1000",
"dongjie_guding_jine": "0.00"
},
{ "leixing": 2, "...": "管事" },
{ "leixing": 6, "...": "商家" }
]
}
```
### 7.2 用户单独配置(三详情页共用)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/houtai/hqdjdongjie?yonghuid=&leixing=` | 查询 |
| POST | `/houtai/xgdjdongjie` | 保存单独配置 / 解冻 |
请求体:
```json
{
"yonghuid": "0000001",
"leixing": 1,
"dandu_dongjie_enabled": true,
"dongjie_mode": 2,
"dongjie_guding_jine": "100.00",
"jiedong_jine": "30.00",
"beizhu": "可选,有值则执行解冻"
}
```
`dongjie_mode=1` 时只读/写 `dongjie_bili`;`mode=2` 时只读/写 `dongjie_guding_jine`。
### 7.3 用户端(小程序)
申请前可增加接口 `GET /yonghu/tixian_dongjie_info?leixing=` 返回:
- 是否启用冻结、模式、比例/固定额
- 当前余额、冻结池、**最大可申请金额**(前端展示用,后端申请时仍强校验)
---
## 八、代码改动范围(确认后)
| 模块 | 改动 |
|------|------|
| `peizhi/models.py` | `WithdrawConfig` 增加总开关 |
| `yonghu/models.py` | 公共配置表 + 三扩展表字段 + 审核表 `benchi_dongjie_jine` + 流水表 |
| `yonghu/freeze_service.py` | **新建** 配置解析 + 冻结拆分算法(纯函数,便于单测) |
| `yonghu/tixian_shenhe_services.py` | `create_audit_application` 接入冻结;驳回增加回滚 |
| `houtai/view.py` | 提现设置读写、单独配置/解冻接口 |
| `kefu/.../Settings.vue` | 公共冻结配置 UI |
| 打手/管事/商家详情页 | 单独冻结 + 解冻 UI |
**不动:** `tixiansq` 收款逻辑(仅打款,不二次冻结)。
---
## 九、边界与防错
| 场景 | 处理 |
|------|------|
| 余额 0 | 拒绝申请 |
| 比例 100% | `max_withdraw=0`,拒绝任何提现申请 |
| 固定池已满 | 与无冻结相同,正常提现 |
| 仅冻结不入提现(固定模式情况A) | 不扣手续费、不写微信打款相关记录 |
| 并发申请 | `select_for_update` 锁扩展表余额行 |
| 驳回 | 同时回滚 `shenqing_jine` + `benchi_dongjie_jine` |
| 金额精度 | 全程 `Decimal`,禁止 `float` |
---
## 十、待你确认的问题
1. **一期范围**:是否只做 **打手/管事/商家 + 自动打款(zddksh)**?组长、审核官、押金、手动打款是否二期?
2. **固定模式「全入池」**:申请金额不足以填满冻结池时,是否**不创建审核单**、仅入池(我按此设计)?
3. **比例模式**:是否按上文「先按余额切冻结比例,再对剩余部分提现」?(避免余额残留可再提)
4. **驳回**:是否同意「提现扣款 + 本次冻结」**全部回滚到可提现余额**?
5. **公共配置**:全局一个总开关 + 每角色独立开关,是否符合你的预期?
---
## 十一、实施顺序(确认后)
1. 迁移 + `freeze_service` 单元测试(用例覆盖第五节所有表格)
2. 接入 `zddksh` / 驳回回滚
3. 后台 API + 提现设置页 UI
4. 三角色详情页 UI + 解冻
5. 小程序申请前提示(可选)
---
**请先看第十节 5 个确认点,回复「对/不对」或补充后,我再按此文档写代码。**
+90
View File
@@ -0,0 +1,90 @@
"""后台 — 提现冻结配置/解冻接口"""
import logging
from django.db import transaction
from rest_framework.parsers import JSONParser
from rest_framework.response import Response
from rest_framework.views import APIView
from yonghu.freeze_service import (
FREEZE_LEIXING_SET,
admin_unfreeze_balance,
get_public_freeze_settings,
get_user_freeze_info,
save_public_freeze_settings,
save_user_freeze_settings,
)
from yonghu.models import UserMain
from .view import verify_kefu_permission
logger = logging.getLogger(__name__)
_WITHDRAW_PERMS = ('5500a', '5500b', '5500c')
def _check_withdraw_perm(permissions):
return any(p in permissions for p in _WITHDRAW_PERMS)
class DongjieUserConfigView(APIView):
"""
用户单独冻结配置 / 解冻(打手佣金1、管事2、押金5、商家6 共用)
GET POST /houtai/hqdjdongjie
"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = (request.data.get('username') or '').strip()
action = (request.data.get('action') or 'get').strip()
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if not _check_withdraw_perm(permissions):
return Response({'code': 403, 'msg': '无权限操作冻结配置'})
yonghuid = (request.data.get('yonghuid') or '').strip()
leixing_raw = request.data.get('leixing')
try:
leixing = int(leixing_raw)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': 'leixing 无效'})
if leixing not in FREEZE_LEIXING_SET:
return Response({'code': 400, 'msg': '该提现类型不支持冻结配置'})
try:
user_main = UserMain.objects.get(yonghuid=yonghuid)
except UserMain.DoesNotExist:
return Response({'code': 404, 'msg': '用户不存在'})
if action == 'get':
return Response({'code': 0, 'data': get_user_freeze_info(user_main, leixing)})
if action == 'save':
try:
with transaction.atomic():
data = save_user_freeze_settings(user_main, leixing, request.data)
return Response({'code': 0, 'msg': '保存成功', 'data': data})
except ValueError as e:
return Response({'code': 400, 'msg': str(e)})
if action == 'unfreeze':
jine = request.data.get('jiedong_jine') or request.data.get('jine')
beizhu = (request.data.get('beizhu') or '').strip()
if jine is None:
return Response({'code': 400, 'msg': '请填写解冻金额'})
try:
with transaction.atomic():
new_balance = admin_unfreeze_balance(
user_main, leixing, jine,
operator_id=username, beizhu=beizhu,
)
info = get_user_freeze_info(user_main, leixing)
info['balance'] = str(new_balance)
return Response({'code': 0, 'msg': '解冻成功', 'data': info})
except ValueError as e:
return Response({'code': 400, 'msg': str(e)})
return Response({'code': 400, 'msg': 'action 无效,支持 get/save/unfreeze'})
+2 -1
View File
@@ -17,7 +17,7 @@ from .view import GetClubConfigView, GetCrossOrderListView, PartnerGetOrderDataV
FaKuanTongJiView, FaKuanLieBiaoView, FaKuanChuLiView, FaKuanChuangJianView,HqbkxxView, BkxgView, \
CaiwuView, CwhybkhqView, HybkjtsjView, SzxxView, CwddhqlxView, CwhqjtddsjView, CwqtczhqView, KhpzhqView, ChzsgcView, \
KhgglView, ShgxgsjView, ZxkfghdsView, KptxwztjbView
from .huashu_views import HuashuListAPIView, HuashuModifyAPIView
from .dongjie_views import DongjieUserConfigView
from .xieyi_views import (
XieyiListAPIView, XieyiModifyAPIView, XieyiImageUploadAPIView,
XieyiImageDeleteAPIView, XieyiSignRecordAPIView,
@@ -80,6 +80,7 @@ urlpatterns = [
path('htxggsxx', UpdateGuanliView.as_view(), name='后台更新管事数据'),
path('hthqtxpz', GetWithdrawSettingsView.as_view(), name='后台获取提现配置'),
path('htxgtxsz', UpdateWithdrawSettingsView.as_view(), name='后台修改提现配置'),
path('hqdjdongjie', DongjieUserConfigView.as_view(), name='后台用户冻结配置解冻'),
path('hthqzzlb', GetZuzhangListView.as_view(), name='后台获取组长列表'),
path('hthqzzxq', GetZuzhangDetailView.as_view(), name='后台获取组长详情页面'),
path('htxgzzxx', UpdateZuzhangView.as_view(), name='后台修改组长信息'),
+13
View File
@@ -6728,8 +6728,10 @@ class GetWithdrawSettingsView(APIView):
# 🆕 获取提现模式(自动1 / 手动2),默认2
from peizhi.models import WithdrawConfig # 确保导入模型
from yonghu.freeze_service import get_public_freeze_settings
config = WithdrawConfig.objects.filter(id=1).first()
withdraw_mode = config.mode if config else 2
freeze_settings = get_public_freeze_settings()
return Response({
'code': 0,
@@ -6740,6 +6742,8 @@ class GetWithdrawSettingsView(APIView):
'quotas': quota_map,
'total_limits': total_limit_map,
'today_totals': today_totals,
'dongjie_quanju_enabled': freeze_settings['dongjie_quanju_enabled'],
'dongjie_roles': freeze_settings['dongjie_roles'],
}
})
@@ -6878,6 +6882,7 @@ class UpdateWithdrawSettingsView(APIView):
# 🆕 修改提现模式(有5500a/b/c任一即可)
from peizhi.models import WithdrawConfig
from yonghu.freeze_service import save_public_freeze_settings
withdraw_mode = request.data.get('withdraw_mode')
if withdraw_mode is not None:
if not any(p in permissions for p in ('5500a', '5500b', '5500c')):
@@ -6888,6 +6893,14 @@ class UpdateWithdrawSettingsView(APIView):
defaults={'mode': int(withdraw_mode)}
)
if 'dongjie_quanju_enabled' in request.data or 'dongjie_roles' in request.data:
if not any(p in permissions for p in ('5500a', '5500b', '5500c')):
return Response({'code': 403, 'msg': '无权限修改冻结配置'})
save_public_freeze_settings(
bool(request.data.get('dongjie_quanju_enabled', False)),
request.data.get('dongjie_roles', []),
)
# 注意:TixianRiTongji 表由提现流程自动更新,此处不直接修改
return Response({'code': 0, 'msg': '设置修改成功'})
+1
View File
@@ -275,6 +275,7 @@ class WithdrawConfig(models.Model):
default=2,
verbose_name='提现模式'
)
dongjie_quanju_enabled = models.BooleanField(default=False, verbose_name='全员冻结总开关')
created_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
updated_time = models.DateTimeField(auto_now=True, verbose_name='更新时间')
+434
View File
@@ -0,0 +1,434 @@
"""
提现余额冻结服务 — 手动/自动打款申请共用。
规则摘要:
- 比例模式:按当前余额切出 freeze_jine 入池,申请金额 apply_jine 为实际提现扣款部分(另扣手续费)。
- 固定模式:冻结池未满则优先填池;申请+池仍不足目标则整笔入池(freeze_only)。
- 驳回:仅退 shenqing_jine 到余额,冻结池不回滚。
"""
from __future__ import annotations
import decimal
import logging
from dataclasses import dataclass
from typing import Optional, Tuple
from django.db import transaction
from peizhi.models import WithdrawConfig
from .models import (
TixianDongjieGonggongPeizhi,
TixianDongjieJilu,
UserDashou,
UserGuanshi,
UserMain,
UserShangjia,
)
logger = logging.getLogger('yonghu.freeze')
DONGJIE_MODE_RATIO = 1
DONGJIE_MODE_FIXED = 2
# 支持冻结的提现类型
FREEZE_LEIXING_SET = frozenset({1, 2, 5, 6})
LEIXING_META = {
1: {
'model': UserDashou,
'balance': 'yue',
'pool': 'dongjie_chi',
'solo_enabled': 'dandu_dongjie_enabled',
'mode': 'dongjie_mode',
'bili': 'dongjie_bili',
'guding': 'dongjie_guding_jine',
'role_name': '打手佣金',
},
5: {
'model': UserDashou,
'balance': 'yajin',
'pool': 'dongjie_chi_yajin',
'solo_enabled': 'dandu_dongjie_yajin_enabled',
'mode': 'dongjie_yajin_mode',
'bili': 'dongjie_yajin_bili',
'guding': 'dongjie_yajin_guding_jine',
'role_name': '打手押金',
},
2: {
'model': UserGuanshi,
'balance': 'yue',
'pool': 'dongjie_chi',
'solo_enabled': 'dandu_dongjie_enabled',
'mode': 'dongjie_mode',
'bili': 'dongjie_bili',
'guding': 'dongjie_guding_jine',
'role_name': '管事分红',
},
6: {
'model': UserShangjia,
'balance': 'yue',
'pool': 'dongjie_chi',
'solo_enabled': 'dandu_dongjie_enabled',
'mode': 'dongjie_mode',
'bili': 'dongjie_bili',
'guding': 'dongjie_guding_jine',
'role_name': '商家余额',
},
}
@dataclass
class FreezeConfig:
mode: int
bili: decimal.Decimal
guding_jine: decimal.Decimal
source: str # 'user' | 'public'
@dataclass
class FreezeSplitResult:
withdraw_jine: decimal.Decimal
freeze_jine: decimal.Decimal
freeze_only: bool
message: str = ''
@dataclass
class ApplyFreezeOutcome:
freeze_only: bool
withdraw_jine: decimal.Decimal
benchi_dongjie_jine: decimal.Decimal
message: str = ''
pool_after: decimal.Decimal = decimal.Decimal('0')
balance_after: decimal.Decimal = decimal.Decimal('0')
def _q(value) -> decimal.Decimal:
return decimal.Decimal(str(value or 0)).quantize(
decimal.Decimal('0.01'), rounding=decimal.ROUND_HALF_UP,
)
def is_freeze_leixing(leixing: int) -> bool:
return int(leixing) in FREEZE_LEIXING_SET
def get_leixing_meta(leixing: int) -> dict:
meta = LEIXING_META.get(int(leixing))
if not meta:
raise ValueError(f'不支持的冻结提现类型: {leixing}')
return meta
def _build_config(mode, bili, guding, source) -> FreezeConfig:
return FreezeConfig(
mode=int(mode or DONGJIE_MODE_RATIO),
bili=_q(bili),
guding_jine=_q(guding),
source=source,
)
def resolve_freeze_config(profile, leixing: int) -> Optional[FreezeConfig]:
"""返回生效冻结配置;None 表示本笔不冻结。"""
leixing = int(leixing)
if leixing not in FREEZE_LEIXING_SET:
return None
meta = get_leixing_meta(leixing)
if getattr(profile, meta['solo_enabled'], False):
return _build_config(
getattr(profile, meta['mode']),
getattr(profile, meta['bili']),
getattr(profile, meta['guding']),
'user',
)
wc = WithdrawConfig.objects.filter(id=1).only('dongjie_quanju_enabled').first()
if not wc or not wc.dongjie_quanju_enabled:
return None
pub = TixianDongjieGonggongPeizhi.objects.filter(leixing=leixing).first()
if not pub or not pub.role_enabled:
return None
return _build_config(pub.dongjie_mode, pub.dongjie_bili, pub.dongjie_guding_jine, 'public')
def compute_freeze_split(
balance: decimal.Decimal,
pool: decimal.Decimal,
config: Optional[FreezeConfig],
apply_jine: decimal.Decimal,
) -> FreezeSplitResult:
"""纯计算:不修改数据库。"""
balance = _q(balance)
pool = _q(pool)
apply_jine = _q(apply_jine)
if apply_jine <= 0:
raise ValueError('提现金额必须大于0')
if apply_jine > balance:
raise ValueError(f'余额不足,当前余额: {balance}')
if config is None:
return FreezeSplitResult(apply_jine, decimal.Decimal('0'), False)
if config.mode == DONGJIE_MODE_RATIO:
if config.bili <= 0:
return FreezeSplitResult(apply_jine, decimal.Decimal('0'), False)
if config.bili >= 1:
return FreezeSplitResult(
decimal.Decimal('0'), apply_jine, True,
message=f'已冻结 {apply_jine} 元进入资金池(比例100%)',
)
freeze_jine = _q(balance * config.bili)
max_withdraw = balance - freeze_jine
if apply_jine > max_withdraw:
raise ValueError(
f'最多可提现 {max_withdraw} 元(需冻结 {freeze_jine} 元,占余额 {config.bili * 100:.2f}%)'
)
return FreezeSplitResult(apply_jine, freeze_jine, False)
if config.mode == DONGJIE_MODE_FIXED:
target = _q(config.guding_jine)
if target <= 0:
return FreezeSplitResult(apply_jine, decimal.Decimal('0'), False)
if pool >= target:
return FreezeSplitResult(apply_jine, decimal.Decimal('0'), False)
if apply_jine + pool < target:
return FreezeSplitResult(
decimal.Decimal('0'), apply_jine, True,
message=f'已冻结 {apply_jine} 元,冻结池 {pool + apply_jine}/{target} 元,满额后可正常提现',
)
gap = target - pool
withdraw_jine = apply_jine - gap
if withdraw_jine <= 0:
return FreezeSplitResult(
decimal.Decimal('0'), apply_jine, True,
message=f'已冻结 {apply_jine} 元,冻结池 {pool + apply_jine}/{target} 元',
)
return FreezeSplitResult(withdraw_jine, gap, False)
return FreezeSplitResult(apply_jine, decimal.Decimal('0'), False)
def get_locked_profile(user_main: UserMain, leixing: int):
meta = get_leixing_meta(leixing)
Model = meta['model']
try:
return Model.objects.select_for_update().get(user=user_main), meta
except Model.DoesNotExist:
raise ValueError(f'用户{user_main.yonghuid}缺少{meta["role_name"]}身份账户')
def _write_freeze_log(yonghuid, leixing, jine, pool_before, pool_after, caozuo, operator_id='', beizhu=''):
TixianDongjieJilu.objects.create(
yonghuid=yonghuid,
leixing=leixing,
jine=_q(jine),
dongjie_chi_before=_q(pool_before),
dongjie_chi_after=_q(pool_after),
caozuo=caozuo,
operator_id=operator_id or '',
beizhu=beizhu or '',
)
def apply_balance_freeze(user_main: UserMain, leixing: int, apply_jine) -> ApplyFreezeOutcome:
"""
提现申请前执行冻结拆分(须在 transaction.atomic 内调用)。
- freeze_only=True:仅入池,不进入提现扣款流程
- 否则返回 withdraw_jine(实际提现扣款额)与 benchi_dongjie_jine
"""
leixing = int(leixing)
apply_jine = _q(apply_jine)
if not is_freeze_leixing(leixing):
return ApplyFreezeOutcome(False, apply_jine, decimal.Decimal('0'))
profile, meta = get_locked_profile(user_main, leixing)
balance = _q(getattr(profile, meta['balance']))
pool = _q(getattr(profile, meta['pool']))
config = resolve_freeze_config(profile, leixing)
split = compute_freeze_split(balance, pool, config, apply_jine)
# 仅将冻结部分划入冻结池;实际提现扣款由 deduct_balance 完成,避免重复扣款
freeze_deduct = split.freeze_jine
if freeze_deduct <= 0:
if split.freeze_only:
freeze_deduct = apply_jine
else:
return ApplyFreezeOutcome(
freeze_only=False,
withdraw_jine=split.withdraw_jine,
benchi_dongjie_jine=decimal.Decimal('0'),
pool_after=pool,
balance_after=balance,
)
if freeze_deduct > balance:
raise ValueError('余额不足,无法完成冻结拆分')
if not split.freeze_only and freeze_deduct + split.withdraw_jine > balance:
raise ValueError('余额不足,无法完成冻结与提现拆分')
new_balance = balance - freeze_deduct
new_pool = pool + freeze_deduct
setattr(profile, meta['balance'], new_balance)
setattr(profile, meta['pool'], new_pool)
update_fields = [meta['balance'], meta['pool']]
if hasattr(profile, 'update_time'):
update_fields.append('update_time')
profile.save(update_fields=update_fields)
if split.freeze_jine > 0:
_write_freeze_log(
user_main.yonghuid, leixing, freeze_deduct, pool, new_pool,
TixianDongjieJilu.CAOZUO_APPLY,
)
logger.info(
'提现申请冻结 yonghuid=%s leixing=%s apply=%s freeze=%s withdraw=%s pool=%s balance=%s',
user_main.yonghuid, leixing, apply_jine, freeze_deduct,
split.withdraw_jine, new_pool, new_balance,
)
if split.freeze_only:
return ApplyFreezeOutcome(
freeze_only=True,
withdraw_jine=decimal.Decimal('0'),
benchi_dongjie_jine=freeze_deduct,
message=split.message or f'已冻结 {freeze_deduct} 元进入资金池',
pool_after=new_pool,
balance_after=new_balance,
)
return ApplyFreezeOutcome(
freeze_only=False,
withdraw_jine=split.withdraw_jine,
benchi_dongjie_jine=freeze_deduct,
pool_after=new_pool,
balance_after=new_balance,
)
def admin_unfreeze_balance(user_main: UserMain, leixing: int, jine, operator_id='', beizhu='') -> decimal.Decimal:
"""管理员解冻:冻结池 → 可提现余额。"""
leixing = int(leixing)
jine = _q(jine)
if jine <= 0:
raise ValueError('解冻金额必须大于0')
profile, meta = get_locked_profile(user_main, leixing)
pool = _q(getattr(profile, meta['pool']))
if jine > pool:
raise ValueError(f'解冻金额不能超过冻结池余额 {pool}')
balance = _q(getattr(profile, meta['balance']))
new_pool = pool - jine
new_balance = balance + jine
setattr(profile, meta['pool'], new_pool)
setattr(profile, meta['balance'], new_balance)
update_fields = [meta['balance'], meta['pool']]
if hasattr(profile, 'update_time'):
update_fields.append('update_time')
profile.save(update_fields=update_fields)
_write_freeze_log(
user_main.yonghuid, leixing, jine, pool, new_pool,
TixianDongjieJilu.CAOZUO_UNFREEZE, operator_id=operator_id, beizhu=beizhu,
)
return new_balance
def get_user_freeze_info(user_main: UserMain, leixing: int) -> dict:
"""后台/详情页展示用。"""
leixing = int(leixing)
meta = get_leixing_meta(leixing)
Model = meta['model']
try:
profile = Model.objects.get(user=user_main)
except Model.DoesNotExist:
return {'exists': False}
balance = _q(getattr(profile, meta['balance']))
pool = _q(getattr(profile, meta['pool']))
config = resolve_freeze_config(profile, leixing)
max_apply = balance
if config and config.mode == DONGJIE_MODE_RATIO and config.bili > 0 and config.bili < 1:
freeze_part = _q(balance * config.bili)
max_apply = balance - freeze_part
return {
'exists': True,
'leixing': leixing,
'role_name': meta['role_name'],
'balance': str(balance),
'dongjie_chi': str(pool),
'dandu_dongjie_enabled': bool(getattr(profile, meta['solo_enabled'], False)),
'dongjie_mode': int(getattr(profile, meta['mode'], DONGJIE_MODE_RATIO)),
'dongjie_bili': str(_q(getattr(profile, meta['bili'], 0))),
'dongjie_guding_jine': str(_q(getattr(profile, meta['guding'], 0))),
'max_apply_jine': str(max_apply),
'freeze_active': config is not None,
'freeze_source': config.source if config else '',
}
def get_public_freeze_settings() -> dict:
wc = WithdrawConfig.objects.filter(id=1).first()
quanju = bool(wc.dongjie_quanju_enabled) if wc else False
roles = []
for leixing in (1, 2, 5, 6):
pub = TixianDongjieGonggongPeizhi.objects.filter(leixing=leixing).first()
meta = get_leixing_meta(leixing)
roles.append({
'leixing': leixing,
'role_name': meta['role_name'],
'role_enabled': bool(pub.role_enabled) if pub else False,
'dongjie_mode': int(pub.dongjie_mode) if pub else DONGJIE_MODE_RATIO,
'dongjie_bili': str(_q(pub.dongjie_bili)) if pub else '0.00',
'dongjie_guding_jine': str(_q(pub.dongjie_guding_jine)) if pub else '0.00',
})
return {'dongjie_quanju_enabled': quanju, 'dongjie_roles': roles}
def save_public_freeze_settings(quanju_enabled: bool, roles: list) -> None:
WithdrawConfig.objects.update_or_create(id=1, defaults={'dongjie_quanju_enabled': bool(quanju_enabled)})
for row in roles:
leixing = int(row['leixing'])
if leixing not in FREEZE_LEIXING_SET:
continue
TixianDongjieGonggongPeizhi.objects.update_or_create(
leixing=leixing,
defaults={
'role_enabled': bool(row.get('role_enabled', False)),
'dongjie_mode': int(row.get('dongjie_mode', DONGJIE_MODE_RATIO)),
'dongjie_bili': _q(row.get('dongjie_bili', 0)),
'dongjie_guding_jine': _q(row.get('dongjie_guding_jine', 0)),
},
)
def save_user_freeze_settings(user_main: UserMain, leixing: int, data: dict) -> dict:
leixing = int(leixing)
profile, meta = get_locked_profile(user_main, leixing)
if 'dandu_dongjie_enabled' in data:
setattr(profile, meta['solo_enabled'], bool(data['dandu_dongjie_enabled']))
if 'dongjie_mode' in data:
setattr(profile, meta['mode'], int(data['dongjie_mode']))
if 'dongjie_bili' in data:
setattr(profile, meta['bili'], _q(data['dongjie_bili']))
if 'dongjie_guding_jine' in data:
setattr(profile, meta['guding'], _q(data['dongjie_guding_jine']))
update_fields = []
for key in (meta['solo_enabled'], meta['mode'], meta['bili'], meta['guding']):
if key in [f.name for f in profile._meta.fields]:
update_fields.append(key)
if hasattr(profile, 'update_time'):
update_fields.append('update_time')
profile.save(update_fields=update_fields)
return get_user_freeze_info(user_main, leixing)
@@ -0,0 +1,182 @@
# Generated manually for freeze feature
from decimal import Decimal
from django.db import migrations, models
def seed_public_freeze(apps, schema_editor):
TixianDongjieGonggongPeizhi = apps.get_model('yonghu', 'TixianDongjieGonggongPeizhi')
for leixing in (1, 2, 5, 6):
TixianDongjieGonggongPeizhi.objects.get_or_create(
leixing=leixing,
defaults={
'role_enabled': False,
'dongjie_mode': 1,
'dongjie_bili': Decimal('0.0000'),
'dongjie_guding_jine': Decimal('0.00'),
},
)
class Migration(migrations.Migration):
dependencies = [
('peizhi', '0020_xcx_sys_peizhi_shangjia_paifa_limit'),
('yonghu', '0022_backfill_tixianjilu_shenqing_jine'),
]
operations = [
migrations.AddField(
model_name='withdrawconfig',
name='dongjie_quanju_enabled',
field=models.BooleanField(default=False, verbose_name='全员冻结总开关'),
),
migrations.AddField(
model_name='userdashou',
name='dongjie_chi',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='佣金冻结资金池'),
),
migrations.AddField(
model_name='userdashou',
name='dandu_dongjie_enabled',
field=models.BooleanField(default=False, verbose_name='佣金单独冻结配置'),
),
migrations.AddField(
model_name='userdashou',
name='dongjie_mode',
field=models.PositiveSmallIntegerField(default=1, verbose_name='佣金冻结方式1比例2固定'),
),
migrations.AddField(
model_name='userdashou',
name='dongjie_bili',
field=models.DecimalField(decimal_places=4, default=0.0, max_digits=5, verbose_name='佣金冻结比例'),
),
migrations.AddField(
model_name='userdashou',
name='dongjie_guding_jine',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='佣金固定冻结额'),
),
migrations.AddField(
model_name='userdashou',
name='dongjie_chi_yajin',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='押金冻结资金池'),
),
migrations.AddField(
model_name='userdashou',
name='dandu_dongjie_yajin_enabled',
field=models.BooleanField(default=False, verbose_name='押金单独冻结配置'),
),
migrations.AddField(
model_name='userdashou',
name='dongjie_yajin_mode',
field=models.PositiveSmallIntegerField(default=1, verbose_name='押金冻结方式1比例2固定'),
),
migrations.AddField(
model_name='userdashou',
name='dongjie_yajin_bili',
field=models.DecimalField(decimal_places=4, default=0.0, max_digits=5, verbose_name='押金冻结比例'),
),
migrations.AddField(
model_name='userdashou',
name='dongjie_yajin_guding_jine',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='押金固定冻结额'),
),
migrations.AddField(
model_name='userguanshi',
name='dongjie_chi',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='冻结资金池'),
),
migrations.AddField(
model_name='userguanshi',
name='dandu_dongjie_enabled',
field=models.BooleanField(default=False, verbose_name='单独冻结配置'),
),
migrations.AddField(
model_name='userguanshi',
name='dongjie_mode',
field=models.PositiveSmallIntegerField(default=1, verbose_name='冻结方式1比例2固定'),
),
migrations.AddField(
model_name='userguanshi',
name='dongjie_bili',
field=models.DecimalField(decimal_places=4, default=0.0, max_digits=5, verbose_name='冻结比例'),
),
migrations.AddField(
model_name='userguanshi',
name='dongjie_guding_jine',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='固定冻结额'),
),
migrations.AddField(
model_name='usershangjia',
name='dongjie_chi',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='冻结资金池'),
),
migrations.AddField(
model_name='usershangjia',
name='dandu_dongjie_enabled',
field=models.BooleanField(default=False, verbose_name='单独冻结配置'),
),
migrations.AddField(
model_name='usershangjia',
name='dongjie_mode',
field=models.PositiveSmallIntegerField(default=1, verbose_name='冻结方式1比例2固定'),
),
migrations.AddField(
model_name='usershangjia',
name='dongjie_bili',
field=models.DecimalField(decimal_places=4, default=0.0, max_digits=5, verbose_name='冻结比例'),
),
migrations.AddField(
model_name='usershangjia',
name='dongjie_guding_jine',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='固定冻结额'),
),
migrations.AddField(
model_name='tixianjilu',
name='benchi_dongjie_jine',
field=models.DecimalField(blank=True, decimal_places=2, default=0, max_digits=10, null=True, verbose_name='本次划入冻结池金额'),
),
migrations.AddField(
model_name='tixianshenhejilu',
name='benchi_dongjie_jine',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='本次划入冻结池金额'),
),
migrations.CreateModel(
name='TixianDongjieGonggongPeizhi',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('leixing', models.PositiveSmallIntegerField(db_index=True, unique=True, verbose_name='提现类型')),
('role_enabled', models.BooleanField(default=False, verbose_name='该角色启用公共冻结')),
('dongjie_mode', models.PositiveSmallIntegerField(choices=[(1, '按比例'), (2, '固定金额')], default=1, verbose_name='冻结方式')),
('dongjie_bili', models.DecimalField(decimal_places=4, default=0.0, max_digits=5, verbose_name='冻结比例')),
('dongjie_guding_jine', models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='固定冻结目标额')),
('update_time', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '提现公共冻结配置',
'verbose_name_plural': '提现公共冻结配置',
'db_table': 'tixian_dongjie_gonggong_peizhi',
},
),
migrations.CreateModel(
name='TixianDongjieJilu',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('yonghuid', models.CharField(db_index=True, max_length=7, verbose_name='用户ID')),
('leixing', models.PositiveSmallIntegerField(db_index=True, verbose_name='提现类型')),
('jine', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='变动金额')),
('dongjie_chi_before', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='变动前冻结池')),
('dongjie_chi_after', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='变动后冻结池')),
('caozuo', models.PositiveSmallIntegerField(choices=[(1, '申请冻结'), (3, '管理员解冻')], verbose_name='操作类型')),
('operator_id', models.CharField(blank=True, default='', max_length=30, verbose_name='操作人')),
('beizhu', models.CharField(blank=True, default='', max_length=200, verbose_name='备注')),
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
],
options={
'verbose_name': '冻结流水',
'verbose_name_plural': '冻结流水',
'db_table': 'tixian_dongjie_jilu',
},
),
migrations.RunPython(seed_public_freeze, migrations.RunPython.noop),
]
+82 -3
View File
@@ -140,6 +140,19 @@ class UserDashou(models.Model):
kaioi_ewai_xiane = models.BooleanField(default=False, verbose_name='是否开启额外提现限额') # True=开启额外限额
ewai_xiane = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name='额外提现限额(元)')
# ---------- 佣金提现冻结(leixing=1)----------
dongjie_chi = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='佣金冻结资金池')
dandu_dongjie_enabled = models.BooleanField(default=False, verbose_name='佣金单独冻结配置')
dongjie_mode = models.PositiveSmallIntegerField(default=1, verbose_name='佣金冻结方式1比例2固定')
dongjie_bili = models.DecimalField(max_digits=5, decimal_places=4, default=0.0000, verbose_name='佣金冻结比例')
dongjie_guding_jine = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='佣金固定冻结额')
# ---------- 押金提现冻结(leixing=5)----------
dongjie_chi_yajin = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='押金冻结资金池')
dandu_dongjie_yajin_enabled = models.BooleanField(default=False, verbose_name='押金单独冻结配置')
dongjie_yajin_mode = models.PositiveSmallIntegerField(default=1, verbose_name='押金冻结方式1比例2固定')
dongjie_yajin_bili = models.DecimalField(max_digits=5, decimal_places=4, default=0.0000, verbose_name='押金冻结比例')
dongjie_yajin_guding_jine = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='押金固定冻结额')
class Meta:
db_table = 'user_dashou'
verbose_name = '打手扩展表'
@@ -172,9 +185,11 @@ class UserShangjia(models.Model):
jinyueliushui = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='商家今月流水')
create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
dongjie_chi = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='冻结资金池')
dandu_dongjie_enabled = models.BooleanField(default=False, verbose_name='单独冻结配置')
dongjie_mode = models.PositiveSmallIntegerField(default=1, verbose_name='冻结方式1比例2固定')
dongjie_bili = models.DecimalField(max_digits=5, decimal_places=4, default=0.0000, verbose_name='冻结比例')
dongjie_guding_jine = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='固定冻结额')
class Meta:
db_table = 'user_shangjia'
@@ -216,6 +231,12 @@ class UserGuanshi(models.Model):
kaioi_ewai_xiane = models.BooleanField(default=False, verbose_name='是否开启额外提现限额')
ewai_xiane = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name='额外提现限额(元)')
dongjie_chi = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='冻结资金池')
dandu_dongjie_enabled = models.BooleanField(default=False, verbose_name='单独冻结配置')
dongjie_mode = models.PositiveSmallIntegerField(default=1, verbose_name='冻结方式1比例2固定')
dongjie_bili = models.DecimalField(max_digits=5, decimal_places=4, default=0.0000, verbose_name='冻结比例')
dongjie_guding_jine = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='固定冻结额')
class Meta:
db_table = 'user_guanshi'
verbose_name = '管事扩展表'
@@ -361,6 +382,10 @@ class Tixianjilu(models.Model):
max_digits=10, decimal_places=2, null=True, blank=True, default=0,
verbose_name='手续费',
)
benchi_dongjie_jine = models.DecimalField(
max_digits=10, decimal_places=2, null=True, blank=True, default=0,
verbose_name='本次划入冻结池金额',
)
# 状态与平台:用整数表示,具体含义由业务逻辑定义
zhuangtai = models.IntegerField(default=1, null=True, blank=True, db_index=True, verbose_name='提现状态1:审核2:成功3:失败')
fangshi = models.IntegerField(null=True, blank=True,db_index=True, verbose_name='收款方式1:微信2:支付宝')
@@ -413,6 +438,9 @@ class TixianShenheJilu(models.Model):
shenqing_jine = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='申请提现金额(扣款额)')
shouxufei = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name='手续费')
shijidaozhang = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='实际到账金额')
benchi_dongjie_jine = models.DecimalField(
max_digits=10, decimal_places=2, default=0, verbose_name='本次划入冻结池金额',
)
feilv = models.DecimalField(max_digits=5, decimal_places=4, default=0, verbose_name='手续费率')
# 审核/驳回
bhliyou = models.TextField(null=True, blank=True, verbose_name='驳回/失败原因')
@@ -503,6 +531,57 @@ class TixianAutoRecord(models.Model):
return f"{self.tixian_id} - {self.get_zhuangtai_display()}"
class TixianDongjieGonggongPeizhi(models.Model):
"""公共冻结配置(按提现类型一行)"""
DONGJIE_MODE_RATIO = 1
DONGJIE_MODE_FIXED = 2
DONGJIE_MODE_CHOICES = (
(DONGJIE_MODE_RATIO, '按比例'),
(DONGJIE_MODE_FIXED, '固定金额'),
)
leixing = models.PositiveSmallIntegerField(unique=True, db_index=True, verbose_name='提现类型')
role_enabled = models.BooleanField(default=False, verbose_name='该角色启用公共冻结')
dongjie_mode = models.PositiveSmallIntegerField(
choices=DONGJIE_MODE_CHOICES, default=DONGJIE_MODE_RATIO, verbose_name='冻结方式',
)
dongjie_bili = models.DecimalField(max_digits=5, decimal_places=4, default=0.0000, verbose_name='冻结比例')
dongjie_guding_jine = models.DecimalField(
max_digits=12, decimal_places=2, default=0.00, verbose_name='固定冻结目标额',
)
update_time = models.DateTimeField(auto_now=True, verbose_name='更新时间')
class Meta:
db_table = 'tixian_dongjie_gonggong_peizhi'
verbose_name = '提现公共冻结配置'
verbose_name_plural = verbose_name
class TixianDongjieJilu(models.Model):
"""冻结/解冻流水"""
CAOZUO_APPLY = 1
CAOZUO_UNFREEZE = 3
CAOZUO_CHOICES = (
(CAOZUO_APPLY, '申请冻结'),
(CAOZUO_UNFREEZE, '管理员解冻'),
)
yonghuid = models.CharField(max_length=7, db_index=True, verbose_name='用户ID')
leixing = models.PositiveSmallIntegerField(db_index=True, verbose_name='提现类型')
jine = models.DecimalField(max_digits=12, decimal_places=2, verbose_name='变动金额')
dongjie_chi_before = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='变动前冻结池')
dongjie_chi_after = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='变动后冻结池')
caozuo = models.PositiveSmallIntegerField(choices=CAOZUO_CHOICES, verbose_name='操作类型')
operator_id = models.CharField(max_length=30, blank=True, default='', verbose_name='操作人')
beizhu = models.CharField(max_length=200, blank=True, default='', verbose_name='备注')
create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
class Meta:
db_table = 'tixian_dongjie_jilu'
verbose_name = '冻结流水'
verbose_name_plural = verbose_name
# yonghu/models.py
class Xiugaijilu(models.Model):
id = models.AutoField(primary_key=True, verbose_name='记录ID')
View File
Whitespace-only changes.
+70
View File
@@ -0,0 +1,70 @@
"""资金冻结拆分算法单元测试"""
import decimal
from unittest import TestCase
from yonghu.freeze_service import (
DONGJIE_MODE_FIXED,
DONGJIE_MODE_RATIO,
FreezeConfig,
compute_freeze_split,
)
class ComputeFreezeSplitTests(TestCase):
def test_no_config_returns_full_withdraw(self):
r = compute_freeze_split(
decimal.Decimal('100'), decimal.Decimal('0'), None, decimal.Decimal('50'),
)
self.assertEqual(r.withdraw_jine, decimal.Decimal('50.00'))
self.assertEqual(r.freeze_jine, decimal.Decimal('0.00'))
self.assertFalse(r.freeze_only)
def test_ratio_partial_freeze(self):
cfg = FreezeConfig(DONGJIE_MODE_RATIO, decimal.Decimal('0.30'), decimal.Decimal('0'), 'public')
r = compute_freeze_split(
decimal.Decimal('100'), decimal.Decimal('0'), cfg, decimal.Decimal('50'),
)
self.assertEqual(r.freeze_jine, decimal.Decimal('30.00'))
self.assertEqual(r.withdraw_jine, decimal.Decimal('50.00'))
self.assertFalse(r.freeze_only)
def test_ratio_exceed_max_raises(self):
cfg = FreezeConfig(DONGJIE_MODE_RATIO, decimal.Decimal('0.30'), decimal.Decimal('0'), 'public')
with self.assertRaises(ValueError):
compute_freeze_split(
decimal.Decimal('100'), decimal.Decimal('0'), cfg, decimal.Decimal('80'),
)
def test_ratio_100_percent_freeze_only(self):
cfg = FreezeConfig(DONGJIE_MODE_RATIO, decimal.Decimal('1'), decimal.Decimal('0'), 'public')
r = compute_freeze_split(
decimal.Decimal('100'), decimal.Decimal('0'), cfg, decimal.Decimal('40'),
)
self.assertTrue(r.freeze_only)
self.assertEqual(r.freeze_jine, decimal.Decimal('40.00'))
self.assertEqual(r.withdraw_jine, decimal.Decimal('0.00'))
def test_fixed_pool_not_full_freeze_only(self):
cfg = FreezeConfig(DONGJIE_MODE_FIXED, decimal.Decimal('0'), decimal.Decimal('500'), 'public')
r = compute_freeze_split(
decimal.Decimal('100'), decimal.Decimal('200'), cfg, decimal.Decimal('80'),
)
self.assertTrue(r.freeze_only)
self.assertEqual(r.freeze_jine, decimal.Decimal('80.00'))
def test_fixed_pool_partial_then_withdraw(self):
cfg = FreezeConfig(DONGJIE_MODE_FIXED, decimal.Decimal('0'), decimal.Decimal('500'), 'public')
r = compute_freeze_split(
decimal.Decimal('200'), decimal.Decimal('400'), cfg, decimal.Decimal('150'),
)
self.assertFalse(r.freeze_only)
self.assertEqual(r.freeze_jine, decimal.Decimal('100.00'))
self.assertEqual(r.withdraw_jine, decimal.Decimal('50.00'))
def test_fixed_pool_already_full(self):
cfg = FreezeConfig(DONGJIE_MODE_FIXED, decimal.Decimal('0'), decimal.Decimal('500'), 'public')
r = compute_freeze_split(
decimal.Decimal('100'), decimal.Decimal('500'), cfg, decimal.Decimal('60'),
)
self.assertEqual(r.freeze_jine, decimal.Decimal('0.00'))
self.assertEqual(r.withdraw_jine, decimal.Decimal('60.00'))
+70 -16
View File
@@ -208,11 +208,13 @@ def check_dashou_has_valid_huiyuan(yonghuid):
return False, '您的会员已全部过期,请先续费后再操作'
def validate_withdraw_eligibility(user_main, leixing, jine):
def validate_withdraw_eligibility(user_main, leixing, jine, balance_check_amount=None):
"""
提现资格校验(对齐手动提现 TixianShenqingView)
balance_check_amount: 余额门槛(含冻结额外占用),默认等于 jine
"""
yonghuid = user_main.yonghuid
check_amt = balance_check_amount if balance_check_amount is not None else jine
if leixing != 6:
fadan_ok, fadan_msg = check_fadan_qiangdan_eligible(yonghuid, 2)
@@ -228,7 +230,7 @@ def validate_withdraw_eligibility(user_main, leixing, jine):
return False, '打手账号已被封禁,无法提现', {}
if dashou.zhuangtai != 1:
return False, '有订单进行中,请完成后提现', {}
if dashou.yue < jine:
if dashou.yue < check_amt:
return False, f'余额不足,当前余额: {dashou.yue}', {}
return True, '', {'nicheng': dashou.nicheng or ''}
@@ -239,7 +241,7 @@ def validate_withdraw_eligibility(user_main, leixing, jine):
return False, '您不是管事身份,无法提现分红', {}
if guanshi.zhuangtai != 1:
return False, '管事账号已被封禁,无法提现', {}
if guanshi.yue < jine:
if guanshi.yue < check_amt:
return False, f'余额不足,当前余额: {guanshi.yue}', {}
return True, '', {'nicheng': '管事用户'}
@@ -274,7 +276,7 @@ def validate_withdraw_eligibility(user_main, leixing, jine):
return False, '打手账号已禁用,无法提取押金', {}
if dashou.zhuangtai != 1:
return False, '您有订单进行中,请完成后提取押金', {}
if dashou.yajin < jine:
if dashou.yajin < check_amt:
return False, f'押金余额不足,当前押金: {dashou.yajin}', {}
unfinished = Dingdan.objects.filter(
jiedan_dashou_id=yonghuid,
@@ -293,7 +295,7 @@ def validate_withdraw_eligibility(user_main, leixing, jine):
return False, '您不是商家身份,无法提现商家余额', {}
if shangjia.zhuangtai != 1:
return False, '商家账号已被禁用,无法提现', {}
if shangjia.yue < jine:
if shangjia.yue < check_amt:
return False, f'商家余额不足,当前余额: {shangjia.yue}', {}
return True, '', {'nicheng': shangjia.nicheng or ''}
@@ -1520,35 +1522,80 @@ def get_audit_for_collect(shenhe_danhao, yonghuid, shenhe_jilu_id=None):
def create_audit_application(user_main, leixing, jine):
"""
提交审核申请:校验 → 扣款 → 写审核表 + 提现记录表
提交审核申请:校验 → 冻结拆分 → 扣款 → 写审核表 + 提现记录表
不创建打款记录表(TixianAutoRecord 仅在收款时创建)
"""
yonghuid = user_main.yonghuid
from yonghu.freeze_service import (
apply_balance_freeze,
compute_freeze_split,
get_leixing_meta,
is_freeze_leixing,
resolve_freeze_config,
)
ok, msg, extra = validate_withdraw_eligibility(user_main, leixing, jine)
yonghuid = user_main.yonghuid
apply_jine = decimal.Decimal(str(jine))
balance_check = apply_jine
withdraw_jine_preview = apply_jine
if is_freeze_leixing(leixing):
meta = get_leixing_meta(leixing)
Model = meta['model']
profile = Model.objects.get(user=user_main)
config = resolve_freeze_config(profile, leixing)
balance = decimal.Decimal(str(getattr(profile, meta['balance'])))
pool = decimal.Decimal(str(getattr(profile, meta['pool'])))
split = compute_freeze_split(balance, pool, config, apply_jine)
if split.freeze_only:
balance_check = apply_jine
withdraw_jine_preview = decimal.Decimal('0')
else:
balance_check = split.freeze_jine + split.withdraw_jine
withdraw_jine_preview = split.withdraw_jine
ok, msg, extra = validate_withdraw_eligibility(
user_main, leixing, withdraw_jine_preview or apply_jine,
balance_check_amount=balance_check,
)
if not ok:
raise ValueError(msg)
feilv = get_tixian_feilv(leixing)
shouxufei, shijidaozhang = calc_fee_amounts(jine, feilv)
if shijidaozhang <= 0:
raise ValueError('提现金额过低,扣除手续费后无实际到账金额,请增加申请金额')
assert_shijidaozhang_below_wechat_limit(shijidaozhang)
shenhe_danhao = generate_shenhe_danhao()
nicheng = extra.get('nicheng', '')
with transaction.atomic():
nicheng = deduct_balance(user_main, leixing, jine) or nicheng
freeze_out = apply_balance_freeze(user_main, leixing, apply_jine)
if freeze_out.freeze_only:
response_data = get_balance_response_data(user_main, leixing)
response_data.update({
'freeze_only': True,
'msg': freeze_out.message,
'dongjie_chi': str(freeze_out.pool_after),
'benchi_dongjie_jine': str(freeze_out.benchi_dongjie_jine),
'leixing': leixing,
})
return response_data
withdraw_jine = freeze_out.withdraw_jine
benchi_dongjie = freeze_out.benchi_dongjie_jine
feilv = get_tixian_feilv(leixing)
shouxufei, shijidaozhang = calc_fee_amounts(withdraw_jine, feilv)
if shijidaozhang <= 0:
raise ValueError('提现金额过低,扣除手续费后无实际到账金额,请增加申请金额')
assert_shijidaozhang_below_wechat_limit(shijidaozhang)
nicheng = deduct_balance(user_main, leixing, withdraw_jine) or nicheng
audit_kwargs = {
'shenhe_danhao': shenhe_danhao,
'yonghuid': yonghuid,
'leixing': leixing,
'zhuangtai': 1,
'shenqing_jine': jine,
'shenqing_jine': withdraw_jine,
'shouxufei': shouxufei,
'shijidaozhang': shijidaozhang,
'benchi_dongjie_jine': benchi_dongjie,
'feilv': feilv,
}
if has_dakuan_mode_field():
@@ -1564,8 +1611,12 @@ def create_audit_application(user_main, leixing, jine):
zhifu=user_main.zhifu or '',
skzhanghao=user_main.skzhanghao or '',
jine=shijidaozhang,
shenqing_jine=withdraw_jine,
shouxufei=shouxufei,
benchi_dongjie_jine=benchi_dongjie,
zhuangtai=1,
fangshi=1,
dakuan_mode=2,
shenheid=None,
bhliyou='',
shenhe_jilu_id=audit.id,
@@ -1582,8 +1633,11 @@ def create_audit_application(user_main, leixing, jine):
'tixianjilu_id': tixian_record.id,
'shouxufei': str(shouxufei),
'shijidaozhang': str(shijidaozhang),
'shenqing_jine': str(withdraw_jine),
'benchi_dongjie_jine': str(benchi_dongjie),
'current_rate': str(feilv.quantize(decimal.Decimal('0.0001'))),
'leixing': leixing,
'zhuangtai': 1,
'freeze_only': False,
})
return response_data
+7
View File
@@ -173,6 +173,13 @@ class TixianZddkshApplyView(APIView):
logger.error(f'提现审核申请异常: {e}', exc_info=True)
return Response({'code': 99, 'msg': '提现申请失败,请稍后重试'})
if data.get('freeze_only'):
return Response({
'code': 0,
'msg': data.get('msg') or '资金已划入冻结池',
'data': data,
})
return Response({'code': 0, 'msg': '提现申请已提交,请等待审核', 'data': data})
finally:
+115
View File
@@ -1909,6 +1909,12 @@ class TixianShenqingView(APIView):
return Response({'code': 5, 'msg': '无效的收款方式,只能是1(微信)或2(支付宝)'},
status=status.HTTP_400_BAD_REQUEST)
# 打手佣金/管事/押金/商家:统一走冻结+扣款(手动打款)
if leixing in (1, 2, 5, 6):
return self._submit_manual_withdraw_with_freeze(
user_main, yonghuid, leixing, jine, fangshi,
)
# 2. 获取对应费率及手续费
current_rate = decimal.Decimal('0.00')
rate_key = {
@@ -2057,6 +2063,115 @@ class TixianShenqingView(APIView):
return Response({'code': 99, 'msg': f'系统错误: {str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def _submit_manual_withdraw_with_freeze(self, user_main, yonghuid, leixing, apply_jine, fangshi):
"""手动打款:冻结拆分 + 扣款 + 创建提现记录(与自动打款冻结规则一致)"""
from yonghu.freeze_service import (
apply_balance_freeze,
compute_freeze_split,
get_leixing_meta,
is_freeze_leixing,
resolve_freeze_config,
)
from yonghu.tixian_shenhe_services import deduct_balance, validate_withdraw_eligibility
rate_key = {1: '5', 2: '6', 5: '11', 6: '10'}.get(leixing)
rate_obj = Lilubiao.objects.filter(fadanpingtai=rate_key).first()
if not rate_obj:
return Response({'code': 41, 'msg': f'提现费率未配置(类型{leixing})'},
status=status.HTTP_400_BAD_REQUEST)
current_rate = decimal.Decimal(str(rate_obj.lilu))
balance_check = apply_jine
withdraw_preview = apply_jine
if is_freeze_leixing(leixing):
meta = get_leixing_meta(leixing)
Model = meta['model']
profile = Model.objects.get(user=user_main)
config = resolve_freeze_config(profile, leixing)
balance = decimal.Decimal(str(getattr(profile, meta['balance'])))
pool = decimal.Decimal(str(getattr(profile, meta['pool'])))
split = compute_freeze_split(balance, pool, config, apply_jine)
if split.freeze_only:
balance_check = apply_jine
withdraw_preview = decimal.Decimal('0')
else:
balance_check = split.freeze_jine + split.withdraw_jine
withdraw_preview = split.withdraw_jine
ok, msg, extra = validate_withdraw_eligibility(
user_main, leixing, withdraw_preview or apply_jine,
balance_check_amount=balance_check,
)
if not ok:
return Response({'code': 400, 'msg': msg}, status=status.HTTP_400_BAD_REQUEST)
try:
with transaction.atomic():
freeze_out = apply_balance_freeze(user_main, leixing, apply_jine)
if freeze_out.freeze_only:
return Response({
'code': 0,
'msg': freeze_out.message or '资金已划入冻结池',
'data': {
'freeze_only': True,
'dongjie_chi': str(freeze_out.pool_after),
'benchi_dongjie_jine': str(freeze_out.benchi_dongjie_jine),
'leixing': leixing,
},
})
withdraw_jine = freeze_out.withdraw_jine
benchi_dongjie = freeze_out.benchi_dongjie_jine
shouxufei = withdraw_jine * current_rate
shijidaozhang = withdraw_jine - shouxufei
if shijidaozhang <= decimal.Decimal('0'):
raise ValueError('提现金额过低,扣除手续费后无实际到账')
nicheng = deduct_balance(user_main, leixing, withdraw_jine) or extra.get('nicheng', '')
tixian_record = Tixianjilu.objects.create(
yonghuid=yonghuid,
avatar=user_main.avatar or '',
phone=user_main.phone or '',
nicheng=nicheng,
leixing=leixing,
zhifu=user_main.zhifu or '',
skzhanghao=user_main.skzhanghao or '',
jine=shijidaozhang,
shenqing_jine=withdraw_jine,
shouxufei=shouxufei,
benchi_dongjie_jine=benchi_dongjie,
zhuangtai=1,
fangshi=fangshi,
dakuan_mode=1,
shenheid=None,
bhliyou='',
)
except ValueError as e:
return Response({'code': 400, 'msg': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
return Response({'code': 99, 'msg': f'创建提现记录失败: {str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response({
'code': 0,
'msg': '提现申请提交成功',
'data': {
'tixian_id': tixian_record.id,
'leixing': leixing,
'jine': str(withdraw_jine.quantize(decimal.Decimal('0.01'))),
'shenqing_jine': str(withdraw_jine.quantize(decimal.Decimal('0.01'))),
'benchi_dongjie_jine': str(benchi_dongjie.quantize(decimal.Decimal('0.01'))),
'fangshi': fangshi,
'zhuangtai': 1,
'create_time': tixian_record.create_time.strftime('%Y-%m-%d %H:%M:%S'),
'current_rate': str(current_rate.quantize(decimal.Decimal('0.0000'))),
'shouxufei': str(shouxufei.quantize(decimal.Decimal('0.01'))),
'shijidaozhang': str(shijidaozhang.quantize(decimal.Decimal('0.01'))),
'tip': f'本次提现手续费率{current_rate * 100}%,手续费{shouxufei}元,实际到账{shijidaozhang}元',
},
})
# ==================== 校验函数 ====================
def _check_dashou_commission_withdraw(self, user_main, jine):