9 Commits

Author SHA1 Message Date
XingQue
332fde50a3 fix: 充值记录列表补全状态/类型展示字段
投诉选单接口返回可读时间与更完整状态文案,不筛选状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 01:05:41 +08:00
XingQue
4799282579 feat: 客服后台可开关投诉拦截提现(按当前俱乐部)
投诉工单页对应 API:GET/POST /gongdan/admin/club-config/

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 00:56:43 +08:00
XingQue
890e465e40 feat: 投诉工单正规化(介入/牵连提现拦截,仅俱乐部开关)
支持被投诉对象、平台介入、协商中状态与组长投诉;提现硬拦含管事向上牵连组长,默认仅 lxs 开启。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 00:50:31 +08:00
XingQue
fa6e679cbe feat: 角色协议(勾选+阅读秒数+卡点)与冻结说明按身份返回
新增协议表/签署留痕、后台与小程序 API;去掉手写依赖;支持抢单/注册/提现等卡点配置。冻结问号文案按角色字典下发。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 20:22:12 +08:00
XingQue
6c96eb3bcb fix: 冻结条件可评估+分期规范化,定时扫描更及时
后台条件真正按罚款率/单量等评估;选项式配置所需字段规范化;Celery 改为每5分钟;小程序说明文案接口补齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 19:36:10 +08:00
XingQue
a505a1ab2d feat: 小程序资金冻结只读接口(汇总/明细/兼容字段)
提现资产与角色信息增量返回 dongjie;新增 fund-freeze-mine/ledger 只读 API,不改扣款逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 19:11:47 +08:00
XingQue
3194a47f52 feat: 资金冻结 P2 客服后台 API(配置/明细/风险/手动解冻)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 18:55:26 +08:00
XingQue
6a1895e566 feat: 资金冻结 P1 打手结单收口网关 + 到期解冻定时任务骨架
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 18:49:44 +08:00
XingQue
d40a27a5ca feat: 资金冻结 P0(表结构+默认关闭入账网关+权限菜单种子)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 18:43:01 +08:00
44 changed files with 4123 additions and 180 deletions

View File

@@ -17,7 +17,7 @@ app.config_from_object('django.conf:settings', namespace='CELERY')
# 自动发现所有已注册app中的tasks.py文件
#app.autodiscover_tasks()
# 自动发现任务(不含 orders.tasks订单超时自动结算已永久关闭
app.autodiscover_tasks(['users.tasks', 'users.ranking_tasks', 'config.tasks'])
app.autodiscover_tasks(['users.tasks', 'users.ranking_tasks', 'config.tasks', 'jituan.tasks'])
# 配置周期性任务Celery Beat Schedule
app.conf.beat_schedule = {
@@ -84,7 +84,17 @@ app.conf.beat_schedule = {
},
'args': (),
'kwargs': {}
}
},
# 5. 资金冻结到期解冻(默认无冻结单时几乎空跑)
'scan_fund_freeze_due': {
'task': 'jituan.tasks.scan_fund_freeze_due',
'schedule': crontab(minute='*/5'),
'options': {
'queue': 'periodic_tasks',
'priority': 4,
},
},
}
@@ -97,6 +107,7 @@ app.conf.task_routes = {
# 'orders.tasks.*': {'queue': 'order_tasks'},
'users.tasks.*': {'queue': 'periodic_tasks'},
'config.tasks.*': {'queue': 'periodic_tasks'},
'jituan.tasks.*': {'queue': 'periodic_tasks'},
}
# 任务序列化

View File

@@ -330,6 +330,9 @@ class KefuGetDashouDetailView(APIView):
'tuikuanliang': dashou.tuikuanliang,
'jinrijiedan': dashou.jinrijiedan,
'yue': float(dashou.yue) if dashou.yue else 0.00,
'dongjie_yue': float(getattr(dashou, 'dongjie_yue', 0) or 0),
'risk_fine_rate': float(getattr(dashou, 'risk_fine_rate', 0) or 0),
'risk_complaint_rate': float(getattr(dashou, 'risk_complaint_rate', 0) or 0),
'zonge': float(dashou.zonge) if dashou.zonge else 0.00,
'jifen': dashou.jifen,
'yajin': float(dashou.yajin) if dashou.yajin else 0.00,

View File

@@ -1,11 +1,12 @@
"""投诉工单常量。"""
# 投诉类型
TYPE_ORDER = 1 # 订单投诉(需订单 OrderID
TYPE_RECHARGE = 2 # 充值投诉(需 Czjilu.dingdan_id
TYPE_PENALTY = 3 # 罚款投诉(需罚单 Penalty.id
TYPE_ORDER = 1 # 订单投诉
TYPE_RECHARGE = 2 # 充值投诉
TYPE_PENALTY = 3 # 罚款投诉
TYPE_GUANSHI = 4 # 管事投诉
TYPE_FRAUD = 5 # 被骗投诉
TYPE_ZUZHANG = 6 # 组长投诉
TYPE_CHOICES = (
(TYPE_ORDER, '订单投诉'),
@@ -13,30 +14,47 @@ TYPE_CHOICES = (
(TYPE_PENALTY, '罚款投诉'),
(TYPE_GUANSHI, '管事投诉'),
(TYPE_FRAUD, '被骗投诉'),
(TYPE_ZUZHANG, '组长投诉'),
)
TYPE_LABELS = dict(TYPE_CHOICES)
# 状态1处理中 2已处理 3不满意可升级4已关闭
# 状态1处理中 2已处理 3不满意 4已关闭 5协商中
STATUS_PROCESSING = 1
STATUS_DONE = 2
STATUS_DISSATISFIED = 3
STATUS_CLOSED = 4
STATUS_NEGOTIATING = 5
STATUS_CHOICES = (
(STATUS_PROCESSING, '处理中'),
(STATUS_DONE, '已处理'),
(STATUS_DISSATISFIED, '不满意'),
(STATUS_CLOSED, '已关闭'),
(STATUS_NEGOTIATING, '协商中'),
)
STATUS_LABELS = dict(STATUS_CHOICES)
# 未结清(拦提现):处理中 / 不满意 / 协商中
OPEN_STATUSES = (STATUS_PROCESSING, STATUS_DISSATISFIED, STATUS_NEGOTIATING)
TARGET_ROLE_DASHOU = 'dashou'
TARGET_ROLE_GUANSHI = 'guanshi'
TARGET_ROLE_ZUZHANG = 'zuzhang'
TARGET_ROLE_PLATFORM = 'platform'
TARGET_ROLE_LABELS = {
TARGET_ROLE_DASHOU: '接单员',
TARGET_ROLE_GUANSHI: '管事',
TARGET_ROLE_ZUZHANG: '组长',
TARGET_ROLE_PLATFORM: '平台',
}
MAX_IMAGES = 9
MAX_SHUOMING_LEN = 500
MAX_SHENGJI_TIMES = 5
OSS_PREFIX = 'gongdan'
# 权限码
PERM_VIEW = 'gdck666' # 查看工单
PERM_HANDLE = 'gdcl666' # 处理工单
PERM_VIEW = 'gdck666'
PERM_HANDLE = 'gdcl666'

View File

View File

View File

@@ -0,0 +1,24 @@
"""种子:俱乐部工单提现拦截开关。默认仅 lxs 打开。"""
from django.core.management.base import BaseCommand
from gongdan.services import ensure_club_gongdan_config
class Command(BaseCommand):
help = '初始化俱乐部工单配置complaint_withdraw_block'
def add_arguments(self, parser):
parser.add_argument('--club-id', default='lxs', help='俱乐部 ID默认 lxs')
parser.add_argument('--enable', action='store_true', default=True, help='打开提现拦截')
parser.add_argument('--disable', action='store_true', help='关闭提现拦截')
def handle(self, *args, **options):
club_id = (options.get('club_id') or 'lxs').strip()
enable = not options.get('disable')
row = ensure_club_gongdan_config(club_id, withdraw_block=enable)
if row.complaint_withdraw_block != enable:
row.complaint_withdraw_block = enable
row.save(update_fields=['complaint_withdraw_block', 'UpdateTime'])
self.stdout.write(self.style.SUCCESS(
f'club={club_id} complaint_withdraw_block={row.complaint_withdraw_block}'
))

View File

@@ -0,0 +1,81 @@
# 工单:被投诉对象 / 平台介入 / 协商中状态 / 俱乐部提现拦截开关
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gongdan', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='gongdan',
name='target_role',
field=models.CharField(blank=True, db_index=True, default='', max_length=16, verbose_name='被投诉角色'),
),
migrations.AddField(
model_name='gongdan',
name='target_user_id',
field=models.CharField(blank=True, db_index=True, default='', max_length=32, verbose_name='被投诉人UID'),
),
migrations.AddField(
model_name='gongdan',
name='platform_intervened',
field=models.BooleanField(db_index=True, default=False, verbose_name='平台已介入'),
),
migrations.AddField(
model_name='gongdan',
name='intervene_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='介入时间'),
),
migrations.AddField(
model_name='gongdan',
name='intervene_by',
field=models.CharField(blank=True, default='', max_length=32, verbose_name='介入操作人UID(仅存库)'),
),
migrations.AddField(
model_name='gongdan',
name='intervene_reason',
field=models.CharField(blank=True, default='', max_length=32, verbose_name='介入原因码'),
),
migrations.AlterField(
model_name='gongdan',
name='leixing',
field=models.IntegerField(
choices=[
(1, '订单投诉'), (2, '充值投诉'), (3, '罚款投诉'),
(4, '管事投诉'), (5, '被骗投诉'), (6, '组长投诉'),
],
db_index=True,
verbose_name='投诉类型',
),
),
migrations.AlterField(
model_name='gongdan',
name='zhuangtai',
field=models.IntegerField(
choices=[
(1, '处理中'), (2, '已处理'), (3, '不满意'),
(4, '已关闭'), (5, '协商中'),
],
db_index=True,
default=1,
verbose_name='状态',
),
),
migrations.CreateModel(
name='ClubGongdanConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16, unique=True, verbose_name='俱乐部')),
('complaint_withdraw_block', models.BooleanField(default=False, verbose_name='投诉拦截提现')),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '俱乐部工单配置',
'db_table': 'club_gongdan_config',
},
),
]

View File

@@ -1,4 +1,4 @@
"""投诉工单模型:主单 + 证据图 + 升级补充说明。"""
"""投诉工单模型:主单 + 证据图 + 升级补充说明 + 俱乐部开关"""
from django.db import models
from gvsdsdk.model_base import QModel
@@ -9,11 +9,14 @@ class Gongdan(QModel):
"""投诉工单主表。"""
gongdan_id = models.CharField(max_length=32, unique=True, db_index=True, verbose_name='工单号')
leixing = models.IntegerField(db_index=True, choices=TYPE_CHOICES, verbose_name='投诉类型')
# 按类型选填关联业务 ID字符串统一存便于展示与复制
order_id = models.CharField(max_length=64, null=True, blank=True, db_index=True, verbose_name='订单ID')
chongzhi_id = models.CharField(max_length=64, null=True, blank=True, db_index=True, verbose_name='充值单ID')
fadan_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='罚单ID')
guanshi_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='被投诉管事UID')
guanshi_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='被投诉管事UID(兼容)')
# 通用被投诉对象(存库;接口对普通端脱敏不展示 UID
target_role = models.CharField(max_length=16, blank=True, default='', db_index=True, verbose_name='被投诉角色')
target_user_id = models.CharField(max_length=32, blank=True, default='', db_index=True, verbose_name='被投诉人UID')
shuoming = models.CharField(max_length=500, verbose_name='投诉说明')
shenqingren_id = models.CharField(max_length=32, db_index=True, verbose_name='申请人UserUID')
@@ -27,6 +30,12 @@ class Gongdan(QModel):
chuliren_name = models.CharField(max_length=64, null=True, blank=True, verbose_name='处理人展示名')
chuli_time = models.DateTimeField(null=True, blank=True, verbose_name='处理时间')
# 平台介入(对外只展示「平台已介入」,不露客服 UID
platform_intervened = models.BooleanField(default=False, db_index=True, verbose_name='平台已介入')
intervene_at = models.DateTimeField(null=True, blank=True, verbose_name='介入时间')
intervene_by = models.CharField(max_length=32, blank=True, default='', verbose_name='介入操作人UID(仅存库)')
intervene_reason = models.CharField(max_length=32, blank=True, default='', verbose_name='介入原因码')
shengji_cishu = models.IntegerField(default=0, verbose_name='升级次数')
CreateTime = models.DateTimeField(auto_now_add=True, db_index=True)
UpdateTime = models.DateTimeField(auto_now=True)
@@ -38,11 +47,10 @@ class Gongdan(QModel):
class GongdanImage(QModel):
"""工单证据图最多9张,以主单当前版本为准;升级时可整体替换)。"""
"""工单证据图最多9张"""
gongdan = models.ForeignKey(
Gongdan, on_delete=models.CASCADE, related_name='images', db_column='gongdan_pk',
)
# 冗余业务工单号(勿命名 gongdan_id会与 FK 访问器冲突)
gongdan_hao = models.CharField(max_length=32, db_index=True, verbose_name='工单号冗余')
url = models.CharField(max_length=512, verbose_name='图片URL')
paixu = models.IntegerField(default=0, verbose_name='排序')
@@ -55,7 +63,7 @@ class GongdanImage(QModel):
class GongdanShengji(QModel):
"""升级/继续投诉补充说明(只记文字与次数;图片改主表 images"""
"""升级/继续投诉补充说明。"""
gongdan = models.ForeignKey(
Gongdan, on_delete=models.CASCADE, related_name='shengji_list', db_column='gongdan_pk',
)
@@ -70,3 +78,16 @@ class GongdanShengji(QModel):
verbose_name = '工单升级记录'
ordering = ['cishu', 'id']
unique_together = [['gongdan', 'cishu']]
class ClubGongdanConfig(QModel):
"""俱乐部工单相关开关(默认全关,仅种子打开的俱乐部生效)。"""
club_id = models.CharField(max_length=16, unique=True, db_index=True, verbose_name='俱乐部')
# 未结清被投诉/牵连时禁止提现
complaint_withdraw_block = models.BooleanField(default=False, verbose_name='投诉拦截提现')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_gongdan_config'
verbose_name = '俱乐部工单配置'

View File

@@ -1,20 +1,22 @@
"""工单业务:创建 / 升级 / 客服处理(幂等与校验)"""
"""工单业务:创建 / 升级 / 客服处理 / 提现拦截 / 列表范围"""
from __future__ import annotations
import logging
import random
import time
from decimal import Decimal
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from gongdan.constants import (
TYPE_ORDER, TYPE_RECHARGE, TYPE_PENALTY, TYPE_GUANSHI, TYPE_FRAUD,
TYPE_ORDER, TYPE_RECHARGE, TYPE_PENALTY, TYPE_GUANSHI, TYPE_FRAUD, TYPE_ZUZHANG,
TYPE_LABELS, STATUS_PROCESSING, STATUS_DONE, STATUS_DISSATISFIED, STATUS_CLOSED,
STATUS_LABELS, MAX_IMAGES, MAX_SHUOMING_LEN, MAX_SHENGJI_TIMES, OSS_PREFIX,
STATUS_NEGOTIATING, STATUS_LABELS, OPEN_STATUSES, MAX_IMAGES, MAX_SHUOMING_LEN,
MAX_SHENGJI_TIMES, TARGET_ROLE_GUANSHI, TARGET_ROLE_ZUZHANG, TARGET_ROLE_LABELS,
TARGET_ROLE_DASHOU, TARGET_ROLE_PLATFORM,
)
from gongdan.models import Gongdan, GongdanImage, GongdanShengji
from gongdan.models import Gongdan, GongdanImage, GongdanShengji, ClubGongdanConfig
logger = logging.getLogger(__name__)
@@ -36,8 +38,107 @@ def _trim(text, max_len=MAX_SHUOMING_LEN):
return (text or '').strip()[:max_len]
def is_complaint_withdraw_block_enabled(club_id: str) -> bool:
cid = (club_id or '').strip()
if not cid:
return False
row = ClubGongdanConfig.query.filter(club_id=cid).first()
return bool(row and row.complaint_withdraw_block)
def ensure_club_gongdan_config(club_id: str, *, withdraw_block: bool = False) -> ClubGongdanConfig:
cid = (club_id or '').strip()
row = ClubGongdanConfig.query.filter(club_id=cid).first()
if not row:
row = ClubGongdanConfig(club_id=cid, complaint_withdraw_block=bool(withdraw_block))
row.save()
return row
def get_club_gongdan_config(club_id: str) -> dict:
cid = (club_id or '').strip()
if not cid:
return {'club_id': '', 'complaint_withdraw_block': False}
row = ClubGongdanConfig.query.filter(club_id=cid).first()
return {
'club_id': cid,
'complaint_withdraw_block': bool(row and row.complaint_withdraw_block),
}
def set_complaint_withdraw_block(club_id: str, enabled: bool) -> dict:
cid = (club_id or '').strip()
if not cid:
raise GongdanError('俱乐部不能为空', 'need_club')
row = ensure_club_gongdan_config(cid, withdraw_block=bool(enabled))
enabled = bool(enabled)
if row.complaint_withdraw_block != enabled:
row.complaint_withdraw_block = enabled
row.save(update_fields=['complaint_withdraw_block', 'UpdateTime'])
return get_club_gongdan_config(cid)
def _user_nicheng(uid: str) -> str:
"""展示昵称:打手 nicheng → 老板 nickname → 用户{UID}。不把 UID 当昵称展示。"""
uid = (uid or '').strip()
if not uid:
return ''
try:
from users.models import UserDashou, UserBoss
from users.business_models import User
ds = UserDashou.query.filter(user__UserUID=uid).select_related('user').first()
if ds and (ds.nicheng or '').strip():
return (ds.nicheng or '').strip()[:32]
boss = UserBoss.query.filter(user__UserUID=uid).select_related('user').first()
if boss and (boss.nickname or '').strip():
return (boss.nickname or '').strip()[:32]
u = User.query.filter(UserUID=uid).first() or User.objects.filter(UserUID=uid).first()
if u and (getattr(u, 'Phone', None) or '').strip():
phone = str(u.Phone).strip()
if len(phone) >= 4:
return f'用户*{phone[-4:]}'
return '用户'
except Exception:
return '用户'
def _resolve_target(leixing, guanshi_id=None, target_role=None, target_user_id=None):
"""归一化被投诉对象;兼容旧 guanshi_id。管事/组长投诉必须选人。"""
role = (target_role or '').strip().lower()
tid = (target_user_id or '').strip()
gid = (guanshi_id or '').strip() or None
if leixing == TYPE_GUANSHI:
role = TARGET_ROLE_GUANSHI
tid = tid or (gid or '')
if not tid:
raise GongdanError('请选择被投诉的管事', 'need_guanshi')
from users.models import UserGuanshi
if not UserGuanshi.query.filter(user__UserUID=tid).exists():
if not UserGuanshi.objects.filter(user__UserUID=tid).exists():
raise GongdanError('管事不存在,请重新选择', 'guanshi_not_found')
return role, tid, tid
if leixing == TYPE_ZUZHANG:
role = TARGET_ROLE_ZUZHANG
if not tid:
raise GongdanError('请选择被投诉的组长', 'need_zuzhang')
from users.models import UserZuzhang
if not UserZuzhang.query.filter(user__UserUID=tid).exists():
if not UserZuzhang.objects.filter(user__UserUID=tid).exists():
raise GongdanError('组长不存在,请重新选择', 'zuzhang_not_found')
return role, tid, None
if role and tid:
if role not in TARGET_ROLE_LABELS and role not in ('order', 'recharge', 'penalty'):
raise GongdanError('无效的被投诉角色', 'bad_target_role')
return role, tid, gid if role == TARGET_ROLE_GUANSHI else None
return role or '', tid or '', gid
def validate_type_refs(leixing, order_id=None, chongzhi_id=None, fadan_id=None, guanshi_id=None):
"""按类型校验必填关联 ID并尽量核对库内是否存在。"""
order_id = (order_id or '').strip() or None
chongzhi_id = (chongzhi_id or '').strip() or None
fadan_id = (fadan_id or '').strip() or None
@@ -72,20 +173,46 @@ def validate_type_refs(leixing, order_id=None, chongzhi_id=None, fadan_id=None,
return None, None, str(fid), None
if leixing == TYPE_GUANSHI:
# 管事UID 选填,有则校验存在
if guanshi_id:
from users.models import UserGuanshi
if not UserGuanshi.objects.filter(user__UserUID=guanshi_id).exists():
raise GongdanError('管事不存在请核对管事ID', 'guanshi_not_found')
return None, None, None, guanshi_id
if leixing == TYPE_FRAUD:
if leixing in (TYPE_FRAUD, TYPE_ZUZHANG):
return None, None, None, None
raise GongdanError('不支持的投诉类型', 'bad_type')
def serialize_gongdan(gd, include_images=True, include_shengji=False):
def _effective_target_uid(gd) -> str:
return (gd.target_user_id or gd.guanshi_id or '').strip()
def _effective_target_role(gd) -> str:
role = (gd.target_role or '').strip()
if role:
return role
if gd.leixing == TYPE_GUANSHI or gd.guanshi_id:
return TARGET_ROLE_GUANSHI
if gd.leixing == TYPE_ZUZHANG:
return TARGET_ROLE_ZUZHANG
return ''
def serialize_gongdan(gd, include_images=True, include_shengji=False, *, viewer_uid=None, for_admin=False):
"""
序列化。默认脱敏:不把申请人/被投诉人/客服 UID 作为展示字段下发。
for_admin=True 时仍不主动展示 UID 列,只多给内部审计需要的标记。
contact_peer_uid 仅当 viewer 是当事方时返回,供 IM 打开(前端勿渲染)。
"""
t_role = _effective_target_role(gd)
t_uid = _effective_target_uid(gd)
relation = ''
if viewer_uid:
if gd.shenqingren_id == viewer_uid:
relation = 'created'
elif t_uid and t_uid == viewer_uid:
relation = 'received'
else:
relation = 'linked' if _viewer_is_linked_zuzhang(viewer_uid, gd) else ''
data = {
'gongdan_id': gd.gongdan_id,
'leixing': gd.leixing,
@@ -93,20 +220,58 @@ def serialize_gongdan(gd, include_images=True, include_shengji=False):
'order_id': gd.order_id or '',
'chongzhi_id': gd.chongzhi_id or '',
'fadan_id': gd.fadan_id or '',
'guanshi_id': gd.guanshi_id or '',
'shuoming': gd.shuoming or '',
'shenqingren_id': gd.shenqingren_id,
'club_id': gd.club_id or '',
'zhuangtai': gd.zhuangtai,
'zhuangtai_label': STATUS_LABELS.get(gd.zhuangtai, str(gd.zhuangtai)),
'chuli_jieguo': gd.chuli_jieguo or '',
'chuliren_id': gd.chuliren_id or '',
'chuliren_name': gd.chuliren_name or '',
# 对外:只显示「平台已介入」,不返回客服 UID
'platform_intervened': bool(gd.platform_intervened),
'intervene_at': gd.intervene_at.isoformat() if gd.intervene_at else '',
'intervene_reason': gd.intervene_reason or '',
'chuli_time': gd.chuli_time.isoformat() if gd.chuli_time else '',
'shengji_cishu': gd.shengji_cishu or 0,
'CreateTime': gd.CreateTime.isoformat() if gd.CreateTime else '',
'UpdateTime': gd.UpdateTime.isoformat() if gd.UpdateTime else '',
'target_role': t_role,
'target_role_label': TARGET_ROLE_LABELS.get(t_role, t_role or ''),
'target_nicheng': _user_nicheng(t_uid),
'complainant_nicheng': _user_nicheng(gd.shenqingren_id),
'relation': relation,
'is_open': gd.zhuangtai in OPEN_STATUSES,
'can_contact': False,
'contact_peer_uid': '',
'contact_peer_nicheng': '',
'can_contact_complainant': False,
'contact_complainant_uid': '',
'contact_complainant_nicheng': '',
}
if viewer_uid and relation in ('created', 'received'):
peer = t_uid if relation == 'created' else gd.shenqingren_id
if peer and peer != viewer_uid:
data['can_contact'] = True
data['contact_peer_uid'] = peer
data['contact_peer_nicheng'] = _user_nicheng(peer)
elif viewer_uid and relation == 'linked':
# 牵连组长:默认可联系主被投诉管事;另可联系投诉人
if t_uid and t_uid != viewer_uid:
data['can_contact'] = True
data['contact_peer_uid'] = t_uid
data['contact_peer_nicheng'] = _user_nicheng(t_uid)
if gd.shenqingren_id and gd.shenqingren_id != viewer_uid:
data['can_contact_complainant'] = True
data['contact_complainant_uid'] = gd.shenqingren_id
data['contact_complainant_nicheng'] = _user_nicheng(gd.shenqingren_id)
if for_admin:
# 客服需要 UID 做内部处理;用户端绝不下发
data['shenqingren_id'] = gd.shenqingren_id or ''
data['target_user_id'] = t_uid or ''
data['guanshi_id'] = gd.guanshi_id or ''
data['chuliren_id'] = gd.chuliren_id or ''
data['chuliren_name'] = gd.chuliren_name or ('平台客服' if gd.platform_intervened or gd.chuliren_id else '')
else:
data['chuliren_name'] = '平台客服' if (gd.platform_intervened or gd.chuliren_name) else ''
if include_images:
data['images'] = [
{'url': img.url, 'paixu': img.paixu}
@@ -124,6 +289,209 @@ def serialize_gongdan(gd, include_images=True, include_shengji=False):
return data
def _viewer_is_linked_zuzhang(viewer_uid: str, gd) -> bool:
"""下级管事被投诉时,其上级组长视为牵连方。"""
t_uid = _effective_target_uid(gd)
t_role = _effective_target_role(gd)
if not t_uid or t_role != TARGET_ROLE_GUANSHI:
return False
try:
from users.models import UserGuanshi
gs = UserGuanshi.objects.filter(user__UserUID=t_uid).first()
if not gs:
return False
return (gs.yaoqingren or '').strip() == str(viewer_uid)
except Exception:
return False
def _sub_guanshi_uids(zuzhang_uid: str):
from users.models import UserGuanshi
rows = UserGuanshi.objects.filter(yaoqingren=str(zuzhang_uid)).select_related('user')
out = []
for r in rows:
uid = getattr(getattr(r, 'user', None), 'UserUID', None)
if uid:
out.append(str(uid))
return out
def open_complaint_q_for_target_uids(uids):
"""未结清且指向这些 UID 的工单条件。"""
uids = [str(u) for u in (uids or []) if u]
if not uids:
return Q(pk__in=[])
return (
Q(zhuangtai__in=OPEN_STATUSES)
& (Q(target_user_id__in=uids) | Q(guanshi_id__in=uids))
)
def check_complaint_blocks_withdraw(user_main, club_id=None):
"""
提现拦截。
返回 (ok, msg, extra)
extra: reason=received|linked, tab, count
"""
try:
from jituan.services.club_user import get_user_club_id
cid = (club_id or get_user_club_id(user_main) or '').strip()
except Exception:
cid = (club_id or '').strip()
if not is_complaint_withdraw_block_enabled(cid):
return True, '', {}
uid = str(user_main.UserUID)
base = Gongdan.query.filter(club_id=cid) if cid else Gongdan.query.all()
# 1) 自己是主被投诉人
qs_recv = base.filter(open_complaint_q_for_target_uids([uid]))
cnt = qs_recv.count()
if cnt:
return False, '你有未处理完的投诉,请处理后再提现', {
'reason': 'received',
'tab': 'received',
'count': cnt,
'code': 'COMPLAINT_BLOCK_WITHDRAW',
}
# 2) 自己是组长,下级管事被投诉 → 牵连
try:
from users.models import UserZuzhang
is_zz = UserZuzhang.query.filter(user=user_main).exists()
except Exception:
is_zz = False
if is_zz:
subs = _sub_guanshi_uids(uid)
if subs:
qs_link = base.filter(open_complaint_q_for_target_uids(subs)).filter(
Q(target_role=TARGET_ROLE_GUANSHI) | Q(target_role='') | Q(leixing=TYPE_GUANSHI)
)
cnt2 = qs_link.count()
if cnt2:
return False, '下级管事有未处理投诉,暂不可提现', {
'reason': 'linked',
'tab': 'linked',
'count': cnt2,
'code': 'COMPLAINT_BLOCK_WITHDRAW',
}
return True, '', {}
def search_complaint_targets(*, role, keyword='', club_id=None, page=1, page_size=20):
"""按昵称/UID 搜管事或组长,供投诉选人(返回 uid 仅供提交,前端展示昵称)。"""
role = (role or '').strip().lower()
keyword = (keyword or '').strip()
page = max(int(page or 1), 1)
page_size = min(max(int(page_size or 20), 1), 50)
cid = (club_id or '').strip()
from users.models import UserGuanshi, UserZuzhang, UserBoss
rows = []
if role == TARGET_ROLE_GUANSHI:
qs = UserGuanshi.query.filter(zhuangtai=1).select_related('user')
if cid:
qs = qs.filter(user__ClubID=cid)
if keyword:
boss_uids = list(
UserBoss.query.filter(nickname__icontains=keyword).values_list('user__UserUID', flat=True)[:80]
)
qs = qs.filter(Q(user__UserUID__icontains=keyword) | Q(user__UserUID__in=boss_uids))
qs = qs.order_by('-id')
total = qs.count()
start = (page - 1) * page_size
for g in qs[start:start + page_size]:
uid = getattr(getattr(g, 'user', None), 'UserUID', None)
if not uid:
continue
rows.append({
'user_id': str(uid),
'nicheng': _user_nicheng(str(uid)),
'role': TARGET_ROLE_GUANSHI,
'role_label': TARGET_ROLE_LABELS[TARGET_ROLE_GUANSHI],
})
elif role == TARGET_ROLE_ZUZHANG:
qs = UserZuzhang.query.filter(zhuangtai=1).select_related('user')
if cid:
qs = qs.filter(user__ClubID=cid)
if keyword:
boss_uids = list(
UserBoss.query.filter(nickname__icontains=keyword).values_list('user__UserUID', flat=True)[:80]
)
qs = qs.filter(Q(user__UserUID__icontains=keyword) | Q(user__UserUID__in=boss_uids))
qs = qs.order_by('-id')
total = qs.count()
start = (page - 1) * page_size
for z in qs[start:start + page_size]:
uid = getattr(getattr(z, 'user', None), 'UserUID', None)
if not uid:
continue
rows.append({
'user_id': str(uid),
'nicheng': _user_nicheng(str(uid)),
'role': TARGET_ROLE_ZUZHANG,
'role_label': TARGET_ROLE_LABELS[TARGET_ROLE_ZUZHANG],
})
else:
raise GongdanError('role 须为 guanshi 或 zuzhang', 'bad_role')
return {'list': rows, 'total': total, 'page': page, 'page_size': page_size, 'role': role}
def list_gongdan_for_user(yonghuid, *, scope='created', club_id=None, page=1, page_size=20):
"""
scope:
created — 我发起的
received — 我被投诉的
linked — 下级牵连(组长)
"""
scope = (scope or 'created').strip().lower()
page = max(int(page or 1), 1)
page_size = min(max(int(page_size or 20), 1), 50)
uid = str(yonghuid)
qs = Gongdan.query.all()
if club_id:
qs = qs.filter(club_id=club_id)
if scope == 'received':
qs = qs.filter(Q(target_user_id=uid) | Q(guanshi_id=uid)).order_by('-CreateTime')
elif scope == 'linked':
subs = _sub_guanshi_uids(uid)
if not subs:
qs = qs.none()
else:
qs = qs.filter(Q(target_user_id__in=subs) | Q(guanshi_id__in=subs)).filter(
Q(target_role=TARGET_ROLE_GUANSHI) | Q(target_role='') | Q(leixing=TYPE_GUANSHI)
).order_by('-CreateTime')
else:
qs = qs.filter(shenqingren_id=uid).order_by('-CreateTime')
total = qs.count()
start = (page - 1) * page_size
rows = list(qs[start:start + page_size])
return {
'list': [serialize_gongdan(g, include_images=False, viewer_uid=uid) for g in rows],
'total': total,
'page': page,
'page_size': page_size,
'scope': scope,
}
def user_can_view_gongdan(gd, yonghuid) -> bool:
uid = str(yonghuid)
if gd.shenqingren_id == uid:
return True
if _effective_target_uid(gd) == uid:
return True
if _viewer_is_linked_zuzhang(uid, gd):
return True
return False
def _replace_images(gd, image_urls):
urls = [u.strip() for u in (image_urls or []) if u and str(u).strip()]
if len(urls) > MAX_IMAGES:
@@ -149,6 +517,8 @@ def create_gongdan(
chongzhi_id=None,
fadan_id=None,
guanshi_id=None,
target_role=None,
target_user_id=None,
image_urls=None,
):
try:
@@ -163,7 +533,10 @@ def create_gongdan(
raise GongdanError('请填写投诉说明', 'need_shuoming')
order_id, chongzhi_id, fadan_id, guanshi_id = validate_type_refs(
leixing, order_id, chongzhi_id, fadan_id, guanshi_id,
leixing, order_id, chongzhi_id, fadan_id, guanshi_id or target_user_id,
)
t_role, t_uid, guanshi_id = _resolve_target(
leixing, guanshi_id=guanshi_id, target_role=target_role, target_user_id=target_user_id or guanshi_id,
)
gid = _gen_gongdan_id()
@@ -179,6 +552,8 @@ def create_gongdan(
chongzhi_id=chongzhi_id,
fadan_id=fadan_id,
guanshi_id=guanshi_id,
target_role=t_role or '',
target_user_id=t_uid or '',
shuoming=text,
shenqingren_id=yonghuid,
club_id=club_id or None,
@@ -186,13 +561,12 @@ def create_gongdan(
shengji_cishu=0,
)
_replace_images(gd, image_urls)
logger.info('创建工单 %s type=%s user=%s', gid, leixing, yonghuid)
logger.info('创建工单 %s type=%s user=%s target=%s/%s', gid, leixing, yonghuid, t_role, t_uid)
return gd
@transaction.atomic
def escalate_gongdan(*, gongdan_id, yonghuid, shuoming, image_urls=None):
"""不满意后继续投诉:写升级记录;可选整体替换证据图。"""
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
if not gd:
raise GongdanError('工单不存在', 'not_found')
@@ -219,13 +593,11 @@ def escalate_gongdan(*, gongdan_id, yonghuid, shuoming, image_urls=None):
gd.zhuangtai = STATUS_PROCESSING
gd.chuli_jieguo = ''
gd.chuli_time = None
# 保留原处理人记录便于审计,但状态回到处理中
gd.save(update_fields=[
'shengji_cishu', 'zhuangtai', 'chuli_jieguo', 'chuli_time', 'UpdateTime',
])
if image_urls is not None:
_replace_images(gd, image_urls)
logger.info('工单升级 %s cishu=%s', gongdan_id, next_cishu)
return gd
@@ -237,13 +609,16 @@ def handle_gongdan(
handler_name,
action,
chuli_jieguo='',
intervene_reason='',
):
"""
客服处理:
action=done → 已处理
action=dissatisfied → 标记不满意(一般由用户侧触发,客服 sparingly
action=close → 关闭
action=processing → 重新标处理中
action:
done/finish/resolve → 已处理
close → 关闭
processing/reopen → 处理中
dissatisfied → 不满意
intervene → 标记平台介入(不改结案状态)
negotiate → 协商中(客服 sparingly
"""
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
if not gd:
@@ -252,22 +627,40 @@ def handle_gongdan(
action = (action or '').strip().lower()
jieguo = _trim(chuli_jieguo)
if action in ('intervene', 'platform_intervene'):
gd.platform_intervened = True
gd.intervene_at = timezone.now()
gd.intervene_by = (handler_uid or '')[:32]
gd.intervene_reason = (intervene_reason or 'kefu')[:32]
if gd.zhuangtai not in OPEN_STATUSES:
gd.zhuangtai = STATUS_PROCESSING
gd.save()
logger.info('工单平台介入 %s by=%s', gongdan_id, handler_uid)
return gd
if action in ('done', 'finish', 'resolve'):
if not jieguo:
raise GongdanError('请填写处理结果', 'need_jieguo')
gd.zhuangtai = STATUS_DONE
gd.chuli_jieguo = jieguo
gd.chuliren_id = handler_uid
gd.chuliren_name = (handler_name or '')[:64]
gd.chuliren_name = (handler_name or '平台客服')[:64]
gd.chuli_time = timezone.now()
if not gd.platform_intervened:
gd.platform_intervened = True
gd.intervene_at = gd.intervene_at or timezone.now()
gd.intervene_by = (handler_uid or '')[:32]
gd.intervene_reason = gd.intervene_reason or 'kefu_done'
elif action in ('close',):
gd.zhuangtai = STATUS_CLOSED
gd.chuli_jieguo = jieguo or gd.chuli_jieguo or '已关闭'
gd.chuliren_id = handler_uid
gd.chuliren_name = (handler_name or '')[:64]
gd.chuliren_name = (handler_name or '平台客服')[:64]
gd.chuli_time = timezone.now()
elif action in ('processing', 'reopen'):
gd.zhuangtai = STATUS_PROCESSING
elif action in ('negotiate',):
gd.zhuangtai = STATUS_NEGOTIATING
elif action in ('dissatisfied',):
gd.zhuangtai = STATUS_DISSATISFIED
else:
@@ -290,3 +683,55 @@ def user_mark_dissatisfied(*, gongdan_id, yonghuid):
gd.zhuangtai = STATUS_DISSATISFIED
gd.save(update_fields=['zhuangtai', 'UpdateTime'])
return gd
@transaction.atomic
def user_resolve_gongdan(*, gongdan_id, yonghuid):
"""投诉人确认已解决(私了结清)。"""
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
if not gd:
raise GongdanError('工单不存在', 'not_found')
if gd.shenqingren_id != yonghuid:
raise GongdanError('仅投诉人可确认解决', 'forbidden')
if gd.zhuangtai not in OPEN_STATUSES:
raise GongdanError('当前状态无需确认解决', 'bad_status')
gd.zhuangtai = STATUS_DONE
gd.chuli_jieguo = gd.chuli_jieguo or '双方协商已解决'
gd.chuli_time = timezone.now()
gd.chuliren_name = gd.chuliren_name or '双方协商'
gd.save()
return gd
@transaction.atomic
def user_start_negotiate(*, gongdan_id, yonghuid):
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
if not gd:
raise GongdanError('工单不存在', 'not_found')
if not user_can_view_gongdan(gd, yonghuid):
raise GongdanError('无权操作此工单', 'forbidden')
if gd.zhuangtai not in (STATUS_PROCESSING, STATUS_DISSATISFIED, STATUS_NEGOTIATING):
raise GongdanError('当前状态不可转协商', 'bad_status')
gd.zhuangtai = STATUS_NEGOTIATING
gd.save(update_fields=['zhuangtai', 'UpdateTime'])
return gd
@transaction.atomic
def user_apply_intervene(*, gongdan_id, yonghuid):
"""用户申请平台介入:打标,仍保持未结清。"""
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
if not gd:
raise GongdanError('工单不存在', 'not_found')
if gd.shenqingren_id != yonghuid and _effective_target_uid(gd) != yonghuid:
raise GongdanError('无权申请介入', 'forbidden')
if gd.zhuangtai not in OPEN_STATUSES:
raise GongdanError('当前状态不可申请介入', 'bad_status')
gd.platform_intervened = True
if not gd.intervene_at:
gd.intervene_at = timezone.now()
gd.intervene_reason = gd.intervene_reason or 'user_apply'
if gd.zhuangtai == STATUS_NEGOTIATING:
gd.zhuangtai = STATUS_PROCESSING
gd.save()
return gd

View File

@@ -7,10 +7,16 @@ from gongdan.views import (
GongdanDetailView,
GongdanEscalateView,
GongdanDissatisfiedView,
GongdanResolveView,
GongdanNegotiateView,
GongdanApplyInterveneView,
GongdanWithdrawCheckView,
GongdanSearchTargetView,
GongdanMetaView,
ChongzhiRecordListView,
GongdanAdminListView,
GongdanAdminHandleView,
GongdanAdminClubConfigView,
)
urlpatterns = [
@@ -20,8 +26,14 @@ urlpatterns = [
path('detail/', GongdanDetailView.as_view(), name='工单详情'),
path('escalate/', GongdanEscalateView.as_view(), name='继续投诉升级'),
path('dissatisfied/', GongdanDissatisfiedView.as_view(), name='标记不满意'),
path('resolve/', GongdanResolveView.as_view(), name='投诉人确认解决'),
path('negotiate/', GongdanNegotiateView.as_view(), name='转协商中'),
path('apply-intervene/', GongdanApplyInterveneView.as_view(), name='申请平台介入'),
path('withdraw-block-check/', GongdanWithdrawCheckView.as_view(), name='提现投诉拦截检查'),
path('search-target/', GongdanSearchTargetView.as_view(), name='搜索被投诉对象'),
path('meta/', GongdanMetaView.as_view(), name='工单枚举'),
path('chongzhi-list/', ChongzhiRecordListView.as_view(), name='我的充值记录'),
path('admin/list/', GongdanAdminListView.as_view(), name='客服工单列表'),
path('admin/handle/', GongdanAdminHandleView.as_view(), name='客服处理工单'),
path('admin/club-config/', GongdanAdminClubConfigView.as_view(), name='工单俱乐部开关'),
]

View File

@@ -20,6 +20,15 @@ from gongdan.services import (
handle_gongdan,
serialize_gongdan,
user_mark_dissatisfied,
list_gongdan_for_user,
user_can_view_gongdan,
user_resolve_gongdan,
user_start_negotiate,
user_apply_intervene,
check_complaint_blocks_withdraw,
search_complaint_targets,
get_club_gongdan_config,
set_complaint_withdraw_block,
)
from utils.oss_utils import upload_to_oss, validate_image
@@ -93,9 +102,14 @@ class GongdanCreateView(APIView):
chongzhi_id=d.get('chongzhi_id') or d.get('dingdanid') or d.get('dingdan_id'),
fadan_id=d.get('fadan_id') or d.get('penalty_id'),
guanshi_id=d.get('guanshi_id'),
target_role=d.get('target_role'),
target_user_id=d.get('target_user_id') or d.get('zuzhang_id') or d.get('guanshi_id'),
image_urls=images,
)
return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True), '提交成功')
return _ok(
serialize_gongdan(gd, include_images=True, include_shengji=True, viewer_uid=request.user.UserUID),
'提交成功',
)
except GongdanError as e:
return _err(str(e))
except Exception as e:
@@ -114,18 +128,24 @@ class GongdanMyListView(APIView):
return self._list(request)
def _list(self, request):
scope = (
request.data.get('scope')
or request.data.get('tab')
or request.query_params.get('scope')
or request.query_params.get('tab')
or 'created'
)
page = int(request.data.get('page') or request.query_params.get('page') or 1)
page_size = min(int(request.data.get('page_size') or request.query_params.get('page_size') or 20), 50)
qs = Gongdan.query.filter(shenqingren_id=request.user.UserUID).order_by('-CreateTime')
total = qs.count()
start = max(page - 1, 0) * page_size
rows = qs[start:start + page_size]
return _ok({
'list': [serialize_gongdan(g, include_images=False) for g in rows],
'total': total,
'page': page,
'page_size': page_size,
})
club_id = _resolve_club(request)
data = list_gongdan_for_user(
request.user.UserUID,
scope=scope,
club_id=club_id,
page=page,
page_size=page_size,
)
return _ok(data)
class GongdanDetailView(APIView):
@@ -139,22 +159,26 @@ class GongdanDetailView(APIView):
gd = Gongdan.query.filter(gongdan_id=gid).first()
if not gd:
return _err('工单不存在', http=404)
# 申请人或有客服查看权限
if gd.shenqingren_id != request.user.UserUID:
from backend.utils import verify_kefu_permission
from gongdan.constants import PERM_VIEW, PERM_HANDLE
kefu, perms_or_resp = verify_kefu_permission(
request, request.data.get('username') or getattr(request.user, 'Phone', None),
)
if not kefu:
return perms_or_resp
perms = perms_or_resp if isinstance(perms_or_resp, list) else []
if PERM_VIEW not in perms and PERM_HANDLE not in perms and '000001' not in perms:
# 超管占位is admin via kefu object
is_admin = getattr(request.user, 'UserType', '') == 'admin' or getattr(request.user, 'IsSuperuser', False)
if not is_admin:
return _err('无权查看', http=403)
return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True))
# 申请人 / 被投诉人 / 牵连组长 / 客服
if user_can_view_gongdan(gd, request.user.UserUID):
return _ok(serialize_gongdan(
gd, include_images=True, include_shengji=True, viewer_uid=request.user.UserUID,
))
from backend.utils import verify_kefu_permission
from gongdan.constants import PERM_VIEW, PERM_HANDLE
kefu, perms_or_resp = verify_kefu_permission(
request, request.data.get('username') or getattr(request.user, 'Phone', None),
)
if not kefu:
return perms_or_resp
perms = perms_or_resp if isinstance(perms_or_resp, list) else []
if PERM_VIEW not in perms and PERM_HANDLE not in perms and '000001' not in perms:
is_admin = getattr(request.user, 'UserType', '') == 'admin' or getattr(request.user, 'IsSuperuser', False)
if not is_admin:
return _err('无权查看', http=403)
return _ok(serialize_gongdan(
gd, include_images=True, include_shengji=True, for_admin=True,
))
class GongdanEscalateView(APIView):
@@ -173,7 +197,10 @@ class GongdanEscalateView(APIView):
shuoming=d.get('shuoming') or '',
image_urls=images,
)
return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True), '已提交补充投诉')
return _ok(
serialize_gongdan(gd, include_images=True, include_shengji=True, viewer_uid=request.user.UserUID),
'已提交补充投诉',
)
except GongdanError as e:
return _err(str(e))
except Exception as e:
@@ -191,7 +218,86 @@ class GongdanDissatisfiedView(APIView):
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
yonghuid=request.user.UserUID,
)
return _ok(serialize_gongdan(gd), '已标记不满意,可继续投诉')
return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已标记不满意,可继续投诉')
except GongdanError as e:
return _err(str(e))
class GongdanResolveView(APIView):
"""投诉人确认已解决。POST /gongdan/resolve/"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
gd = user_resolve_gongdan(
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
yonghuid=request.user.UserUID,
)
return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已确认解决')
except GongdanError as e:
return _err(str(e))
class GongdanNegotiateView(APIView):
"""转协商中。POST /gongdan/negotiate/"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
gd = user_start_negotiate(
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
yonghuid=request.user.UserUID,
)
return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已进入协商')
except GongdanError as e:
return _err(str(e))
class GongdanApplyInterveneView(APIView):
"""申请平台介入。POST /gongdan/apply-intervene/"""
permission_classes = [IsAuthenticated]
def post(self, request):
try:
gd = user_apply_intervene(
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
yonghuid=request.user.UserUID,
)
return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已申请平台介入')
except GongdanError as e:
return _err(str(e))
class GongdanWithdrawCheckView(APIView):
"""提现前投诉拦截检查。POST /gongdan/withdraw-block-check/"""
permission_classes = [IsAuthenticated]
def post(self, request):
ok, msg, extra = check_complaint_blocks_withdraw(request.user, club_id=_resolve_club(request))
return _ok({
'blocked': not ok,
'msg': msg,
'reason': extra.get('reason') or '',
'tab': extra.get('tab') or '',
'count': extra.get('count') or 0,
})
class GongdanSearchTargetView(APIView):
"""搜索被投诉管事/组长。POST /gongdan/search-target/"""
permission_classes = [IsAuthenticated]
def post(self, request):
d = request.data
try:
data = search_complaint_targets(
role=d.get('role') or d.get('target_role') or '',
keyword=d.get('keyword') or d.get('q') or '',
club_id=_resolve_club(request),
page=d.get('page') or 1,
page_size=d.get('page_size') or 20,
)
return _ok(data)
except GongdanError as e:
return _err(str(e))
@@ -233,19 +339,41 @@ class ChongzhiRecordListView(APIView):
CZJILU_LEIXING_SHANGJIA: '商家充值',
5: '考核',
6: '罚款',
7: '转盘',
}
# 与 czjilu 实际状态一致:待支付/已取消/已支付等全量展示,不筛选
status_map = {
9: '待支付',
3: '已支付',
2: '已取消',
1: '处理中',
8: '履约失败',
10: '业务失败',
0: '未知',
}
status_map = {9: '待支付', 3: '已支付', 2: '已取消', 8: '履约失败', 10: '业务失败'}
rows = []
for o in qs[start:start + page_size]:
z = o.zhuangtai
try:
z_int = int(z) if z is not None else None
except (TypeError, ValueError):
z_int = None
ct = ''
if getattr(o, 'CreateTime', None):
try:
ct = o.CreateTime.strftime('%Y-%m-%d %H:%M:%S')
except Exception:
ct = o.CreateTime.isoformat()
rows.append({
'dingdan_id': o.dingdan_id,
'leixing': o.leixing,
'leixing_label': type_map.get(o.leixing, str(o.leixing)),
'leixing_label': type_map.get(o.leixing, f'类型{o.leixing}'),
'jine': str(o.jine),
'zhuangtai': o.zhuangtai,
'zhuangtai_label': status_map.get(o.zhuangtai, str(o.zhuangtai)),
'zhuangtai': z_int if z_int is not None else z,
'zhuangtai_label': status_map.get(z_int, f'状态{z}' if z is not None else '未知'),
'shuoming': o.shuoming or '',
'CreateTime': o.CreateTime.isoformat() if getattr(o, 'CreateTime', None) else '',
'club_id': getattr(o, 'club_id', '') or '',
'CreateTime': ct,
})
return _ok({'list': rows, 'total': total, 'page': page, 'page_size': page_size})
@@ -294,24 +422,29 @@ class GongdanAdminListView(APIView):
only_escalated = str(request.data.get('only_escalated') or '').lower() in ('1', 'true', 'yes')
need_handle = str(request.data.get('need_handle') or '').lower() in ('1', 'true', 'yes')
if need_handle:
qs = qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3))
qs = qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5))
elif zhuangtai not in (None, ''):
qs = qs.filter(zhuangtai=int(zhuangtai))
if only_escalated:
qs = qs.filter(shengji_cishu__gt=0).filter(Q(zhuangtai=1) | Q(zhuangtai=3))
qs = qs.filter(shengji_cishu__gt=0).filter(Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5))
if leixing not in (None, ''):
qs = qs.filter(leixing=int(leixing))
if keyword:
qs = qs.filter(
Q(gongdan_id__icontains=keyword)
| Q(shenqingren_id__icontains=keyword)
| Q(target_user_id__icontains=keyword)
| Q(guanshi_id__icontains=keyword)
| Q(order_id__icontains=keyword)
| Q(chongzhi_id__icontains=keyword)
| Q(fadan_id__icontains=keyword)
)
total = qs.count()
start = max(page - 1, 0) * page_size
rows = [serialize_gongdan(g, include_images=False) for g in qs[start:start + page_size]]
rows = [
serialize_gongdan(g, include_images=False, for_admin=True)
for g in qs[start:start + page_size]
]
# 统计基于俱乐部范围全量(不受当前筛选影响),方便客服一眼看到待办量
stats_qs = Gongdan.query.all()
@@ -326,10 +459,11 @@ class GongdanAdminListView(APIView):
'done': stats_qs.filter(zhuangtai=2).count(),
'dissatisfied': stats_qs.filter(zhuangtai=3).count(),
'closed': stats_qs.filter(zhuangtai=4).count(),
'negotiating': stats_qs.filter(zhuangtai=5).count(),
'escalated': stats_qs.filter(shengji_cishu__gt=0).filter(
Q(zhuangtai=1) | Q(zhuangtai=3)
Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5)
).count(),
'need_handle': stats_qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3)).count(),
'need_handle': stats_qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5)).count(),
}
return _ok({
'list': rows,
@@ -358,10 +492,59 @@ class GongdanAdminHandleView(APIView):
handler_name=handler_name,
action=d.get('action') or 'done',
chuli_jieguo=d.get('chuli_jieguo') or d.get('jieguo') or '',
intervene_reason=d.get('intervene_reason') or '',
)
return _ok(
serialize_gongdan(gd, include_images=True, include_shengji=True, for_admin=True),
'处理成功',
)
return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True), '处理成功')
except GongdanError as e:
return _err(str(e))
except Exception as e:
logger.error('处理工单失败: %s', e, exc_info=True)
return _err('处理失败', http=500)
class GongdanAdminClubConfigView(APIView):
"""
当前俱乐部工单开关。
GET/POST /gongdan/admin/club-config/
字段complaint_withdraw_block未结清投诉/牵连时禁止提现)
"""
permission_classes = [IsAuthenticated]
def _club_id(self, request):
try:
from jituan.services.club_context import resolve_club_id_from_request
return (resolve_club_id_from_request(request) or '').strip()
except Exception:
return (getattr(request.user, 'ClubID', None) or getattr(request.user, 'club_id', None) or '').strip()
def get(self, request):
kefu, perms, err = _require_kefu_perm(request, PERM_VIEW, PERM_HANDLE)
if err:
return err
club_id = self._club_id(request)
if not club_id:
return _err('请先选择俱乐部')
return _ok(get_club_gongdan_config(club_id))
def post(self, request):
kefu, perms, err = _require_kefu_perm(request, PERM_HANDLE)
if err:
return err
club_id = self._club_id(request)
if not club_id:
return _err('请先选择俱乐部')
raw = request.data.get('complaint_withdraw_block')
if raw is None:
return _err('缺少 complaint_withdraw_block')
if isinstance(raw, str):
enabled = raw.strip().lower() in ('1', 'true', 'yes', 'on')
else:
enabled = bool(raw)
try:
data = set_complaint_withdraw_block(club_id, enabled)
return _ok(data, '已保存')
except GongdanError as e:
return _err(str(e))

View File

@@ -0,0 +1,125 @@
"""写入资金冻结相关权限码,并可按俱乐部挂到角色(可重复执行)。
权限码:
djcfg001 资金冻结规则配置
djled001 资金冻结明细/手动解冻
djrisk001 罚款投诉风险指标
用法:
python manage.py seed_fund_freeze_perm
python manage.py seed_fund_freeze_perm --club-id lxs --role-name 管理员
python manage.py seed_fund_freeze_perm --all-clubs --role-name 管理员 --dry-run
"""
import uuid
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from gvsdsdk.models import Permission, Role, RolePermission
from jituan.models import Club
PERMS = [
('djcfg001', '资金冻结规则配置', '配置本俱乐部各角色冻结比例/分期/解冻条件', '资金冻结'),
('djled001', '资金冻结明细', '查看冻结流水并手动解冻', '资金冻结'),
('djrisk001', '罚款投诉风险指标', '查看罚款率/投诉率等解冻相关指标', '资金冻结'),
]
class Command(BaseCommand):
help = '写入资金冻结权限码,并按俱乐部挂到角色'
def add_arguments(self, parser):
parser.add_argument('--club-id', default='', help='仅处理该俱乐部')
parser.add_argument('--all-clubs', action='store_true')
parser.add_argument('--role-name', default='', help='角色名;空则只确保权限码')
parser.add_argument('--dry-run', action='store_true')
def handle(self, *args, **options):
dry_run = options['dry_run']
club_id = (options.get('club_id') or '').strip()
all_clubs = options.get('all_clubs')
role_name = (options.get('role_name') or '').strip()
sample = Permission.objects.filter(PermStatus=1).first()
tenant_uuid = sample.TenantUUID if sample else uuid.UUID(
'00000000-0000-0000-0000-000000000001'
).bytes
perm_objs = []
for code, name, desc, cat in PERMS:
perm = Permission.objects.filter(PermCode=code).first()
if perm:
self.stdout.write(f' 权限码已存在: {code}')
if not dry_run:
changed = False
if perm.PermName != name:
perm.PermName = name
changed = True
if (perm.PermDesc or '') != desc:
perm.PermDesc = desc
changed = True
if (perm.PermCategory or '') != cat:
perm.PermCategory = cat
changed = True
if changed:
perm.save(update_fields=['PermName', 'PermDesc', 'PermCategory'])
else:
if dry_run:
self.stdout.write(f'would create perm {code}')
else:
perm = Permission.objects.create(
PermUUID=uuid.uuid4().bytes,
TenantUUID=tenant_uuid,
PermCode=code,
PermName=name,
PermDesc=desc,
PermCategory=cat,
PermStatus=1,
CreateTime=timezone.now(),
UpdateTime=timezone.now(),
)
self.stdout.write(self.style.SUCCESS(f' 已创建权限码: {code}'))
if perm:
perm_objs.append(perm)
if not role_name:
self.stdout.write('未指定 --role-name跳过挂角色')
return
clubs = []
if all_clubs:
clubs = list(Club.query.filter(status=1).values_list('club_id', flat=True))
elif club_id:
clubs = [club_id]
else:
self.stdout.write(self.style.WARNING('请指定 --club-id 或 --all-clubs'))
return
with transaction.atomic():
for cid in clubs:
roles = Role.objects.filter(RoleName=role_name, ClubID=cid, RoleStatus=1)
if not roles.exists():
# 兼容无 ClubID 字段旧数据:仅精确 club 时再试全局同名
roles = Role.objects.filter(RoleName=role_name, RoleStatus=1)
roles = [r for r in roles if (getattr(r, 'ClubID', None) or '') == cid]
for role in roles:
for perm in perm_objs:
exists = RolePermission.objects.filter(
RoleUUID=role.RoleUUID, PermUUID=perm.PermUUID,
).exists()
if exists:
self.stdout.write(f' 已挂: {cid} {role_name} <- {perm.PermCode}')
continue
if dry_run:
self.stdout.write(f'would link {cid} {role_name} <- {perm.PermCode}')
else:
RolePermission.objects.create(
RoleUUID=role.RoleUUID,
PermUUID=perm.PermUUID,
CreateTime=timezone.now(),
)
self.stdout.write(self.style.SUCCESS(
f' 已挂: {cid} {role_name} <- {perm.PermCode}'
))

View File

@@ -50,6 +50,30 @@ EXTRA_PERMISSIONS = [
'审核本俱乐部提现(同意/拒绝/自动打款)。跨俱乐部仅集团高管;须角色 ClubID + 数据范围任职。',
'提现管理',
),
(
'djcfg001',
'资金冻结规则配置',
'配置本俱乐部各角色冻结比例/分期/解冻条件',
'资金冻结',
),
(
'djled001',
'资金冻结明细',
'查看冻结流水并手动解冻',
'资金冻结',
),
(
'djrisk001',
'罚款投诉风险指标',
'查看罚款率/投诉率等解冻相关指标',
'资金冻结',
),
(
'xycfg001',
'角色协议配置',
'配置打手/管事/组长用户协议并发布版本',
'小程序配置',
),
# ---------- 店铺管理999 系列:列表/详情/复制/提现等) ----------
('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'),
('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'),

View File

@@ -37,3 +37,12 @@ class Command(BaseCommand):
call_command('seed_gvsdsdk_permissions')
except Exception as e:
self.stdout.write(self.style.WARNING(f'seed_gvsdsdk_permissions 跳过: {e}'))
try:
from django.core.management import call_command
call_command('seed_fund_freeze_perm')
except Exception as e:
self.stdout.write(self.style.WARNING(f'seed_fund_freeze_perm 跳过: {e}'))
try:
call_command('seed_role_agreement_perm')
except Exception as e:
self.stdout.write(self.style.WARNING(f'seed_role_agreement_perm 跳过: {e}'))

View File

@@ -0,0 +1,119 @@
"""写入角色协议权限码,并可按俱乐部挂到角色。
权限码:
xycfg001 角色协议配置
用法:
python manage.py seed_role_agreement_perm
python manage.py seed_role_agreement_perm --club-id lxs --role-name 管理员
"""
import uuid
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from gvsdsdk.models import Permission, Role, RolePermission
from jituan.models import Club
PERMS = [
('xycfg001', '角色协议配置', '配置打手/管事/组长用户协议并发布版本', '小程序配置'),
]
class Command(BaseCommand):
help = '写入角色协议权限码,并按俱乐部挂到角色'
def add_arguments(self, parser):
parser.add_argument('--club-id', default='', help='仅处理该俱乐部')
parser.add_argument('--all-clubs', action='store_true')
parser.add_argument('--role-name', default='', help='角色名;空则只确保权限码')
parser.add_argument('--dry-run', action='store_true')
def handle(self, *args, **options):
dry_run = options['dry_run']
club_id = (options.get('club_id') or '').strip()
all_clubs = options.get('all_clubs')
role_name = (options.get('role_name') or '').strip()
sample = Permission.objects.filter(PermStatus=1).first()
tenant_uuid = sample.TenantUUID if sample else uuid.UUID(
'00000000-0000-0000-0000-000000000001'
).bytes
perm_objs = []
for code, name, desc, cat in PERMS:
perm = Permission.objects.filter(PermCode=code).first()
if perm:
self.stdout.write(f' 权限码已存在: {code}')
if not dry_run:
changed = False
if perm.PermName != name:
perm.PermName = name
changed = True
if (perm.PermDesc or '') != desc:
perm.PermDesc = desc
changed = True
if (perm.PermCategory or '') != cat:
perm.PermCategory = cat
changed = True
if changed:
perm.save(update_fields=['PermName', 'PermDesc', 'PermCategory'])
else:
if dry_run:
self.stdout.write(f'would create perm {code}')
else:
perm = Permission.objects.create(
PermUUID=uuid.uuid4().bytes,
TenantUUID=tenant_uuid,
PermCode=code,
PermName=name,
PermDesc=desc,
PermCategory=cat,
PermStatus=1,
CreateTime=timezone.now(),
UpdateTime=timezone.now(),
)
self.stdout.write(self.style.SUCCESS(f' 已创建权限码: {code}'))
if perm:
perm_objs.append(perm)
if not role_name:
self.stdout.write('未指定 --role-name跳过挂角色')
return
clubs = []
if all_clubs:
clubs = list(Club.query.filter(status=1).values_list('club_id', flat=True))
elif club_id:
clubs = [club_id]
else:
self.stdout.write(self.style.WARNING('请指定 --club-id 或 --all-clubs'))
return
with transaction.atomic():
for cid in clubs:
roles = Role.objects.filter(RoleName=role_name, ClubID=cid, RoleStatus=1)
if not roles.exists():
roles = Role.objects.filter(RoleName=role_name, RoleStatus=1)
roles = [r for r in roles if (getattr(r, 'ClubID', None) or '') == cid]
for role in roles:
for perm in perm_objs:
exists = RolePermission.objects.filter(
RoleUUID=role.RoleUUID, PermUUID=perm.PermUUID,
).exists()
if exists:
self.stdout.write(f' 已挂: {cid} {role_name} <- {perm.PermCode}')
continue
if dry_run:
self.stdout.write(f'would link {cid} {role_name} <- {perm.PermCode}')
else:
RolePermission.objects.create(
RoleUUID=role.RoleUUID,
PermUUID=perm.PermUUID,
CreateTime=timezone.now(),
)
self.stdout.write(self.style.SUCCESS(
f' 已挂: {cid} {role_name} <- {perm.PermCode}'
))

View File

@@ -0,0 +1,131 @@
# Generated manually for fund freeze P0 tables
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0016_club_order_grab_link'),
]
operations = [
migrations.CreateModel(
name='ClubFundFreezeConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16, verbose_name='俱乐部')),
('role', models.CharField(choices=[('dashou', '打手'), ('guanshi', '管事'), ('zuzhang', '组长'), ('shenheguan', '考核官')], db_index=True, max_length=16, verbose_name='角色')),
('enabled', models.BooleanField(default=False, verbose_name='是否启用冻结')),
('freeze_mode', models.SmallIntegerField(choices=[(1, '按比例'), (2, '固定额'), (3, '比例+单笔上限')], default=1)),
('freeze_rate', models.DecimalField(decimal_places=6, default=Decimal('0'), help_text='0.2=20%', max_digits=8, verbose_name='冻结比例')),
('freeze_fixed', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12)),
('freeze_cap', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, verbose_name='单笔冻结上限(0=不限)')),
('min_credit_to_freeze', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, verbose_name='低于此入账不冻')),
('source_scope', models.JSONField(blank=True, default=list, help_text='空=全部来源;如 ["order_settle"]', verbose_name='入账来源白名单')),
('unfreeze_trigger', models.CharField(choices=[('time_only', '仅到期'), ('condition_only', '仅条件达标'), ('time_and_condition', '到期且达标'), ('time_or_condition', '到期或达标'), ('manual_only', '仅手动')], default='time_only', max_length=32)),
('schedule_mode', models.CharField(choices=[('all_at_once', '一次全解'), ('installment', '分期解冻')], default='all_at_once', max_length=16)),
('installment_json', models.JSONField(blank=True, default=dict, help_text='{"steps":[...]}', verbose_name='分期计划')),
('condition_json', models.JSONField(blank=True, default=dict, verbose_name='解冻条件')),
('freeze_min_days', models.IntegerField(default=0, verbose_name='最短冻结天数')),
('remark', models.CharField(blank=True, default='', max_length=255)),
('updated_by', models.CharField(blank=True, default='', max_length=32)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '资金冻结规则',
'db_table': 'club_fund_freeze_config',
'unique_together': {('club_id', 'role')},
},
),
migrations.CreateModel(
name='FundFreezeLedger',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16)),
('user_id', models.CharField(db_index=True, max_length=16, verbose_name='UserUID')),
('role', models.CharField(db_index=True, max_length=16)),
('amount_total', models.DecimalField(decimal_places=2, max_digits=12)),
('amount_unfrozen', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12)),
('status', models.SmallIntegerField(db_index=True, default=1)),
('frozen_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('source_type', models.CharField(db_index=True, max_length=32)),
('order_id', models.CharField(blank=True, db_index=True, default='', max_length=64)),
('czjilu_id', models.CharField(blank=True, db_index=True, default='', max_length=64)),
('biz_id', models.CharField(max_length=64, verbose_name='幂等业务键')),
('freeze_reason', models.CharField(blank=True, default='', max_length=255)),
('config_snapshot', models.JSONField(blank=True, default=dict)),
('current_step', models.IntegerField(default=1)),
('next_eligible_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '资金冻结主单',
'db_table': 'fund_freeze_ledger',
'unique_together': {('club_id', 'role', 'source_type', 'biz_id')},
},
),
migrations.CreateModel(
name='FundUnfreezeEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ledger_id', models.BigIntegerField(db_index=True)),
('club_id', models.CharField(db_index=True, max_length=16)),
('user_id', models.CharField(db_index=True, max_length=16)),
('role', models.CharField(max_length=16)),
('step_seq', models.IntegerField(default=1)),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('unfrozen_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('trigger', models.CharField(default='schedule', max_length=16)),
('reason', models.CharField(blank=True, default='', max_length=255)),
('operator_id', models.CharField(blank=True, default='', max_length=32)),
('condition_snapshot', models.JSONField(blank=True, default=dict)),
],
options={
'verbose_name': '资金解冻事件',
'db_table': 'fund_unfreeze_event',
},
),
migrations.CreateModel(
name='FundRiskStatDaily',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16)),
('user_id', models.CharField(db_index=True, max_length=16)),
('role', models.CharField(db_index=True, max_length=16)),
('day', models.DateField(db_index=True)),
('completed_orders', models.IntegerField(default=0)),
('fine_count', models.IntegerField(default=0)),
('complaint_count', models.IntegerField(default=0)),
('valid_invite_count', models.IntegerField(default=0)),
('meta_json', models.JSONField(blank=True, default=dict)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '资金风险日汇总',
'db_table': 'fund_risk_stat_daily',
'unique_together': {('club_id', 'user_id', 'role', 'day')},
},
),
migrations.AddIndex(
model_name='fundfreezeledger',
index=models.Index(fields=['status', 'next_eligible_at'], name='idx_ffl_status_next'),
),
migrations.AddIndex(
model_name='fundfreezeledger',
index=models.Index(fields=['user_id', 'role', 'status'], name='idx_ffl_user_role'),
),
migrations.AddIndex(
model_name='fundfreezeledger',
index=models.Index(fields=['club_id', 'status', 'frozen_at'], name='idx_ffl_club_st'),
),
migrations.AddIndex(
model_name='fundriskstatdaily',
index=models.Index(fields=['club_id', 'role', 'day'], name='idx_frsd_club_role_day'),
),
]

View File

@@ -0,0 +1,66 @@
# Generated manually for role agreement tables
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0017_fund_freeze'),
]
operations = [
migrations.CreateModel(
name='ClubRoleAgreement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16)),
('role', models.CharField(choices=[('dashou', '打手'), ('guanshi', '管事'), ('zuzhang', '组长')], db_index=True, max_length=16)),
('enabled', models.BooleanField(default=False, verbose_name='是否要求签署')),
('title', models.CharField(blank=True, default='', max_length=128, verbose_name='协议标题')),
('content_text', models.TextField(blank=True, default='', verbose_name='协议正文')),
('images_json', models.JSONField(blank=True, default=list, verbose_name='说明图片URL列表')),
('version', models.PositiveIntegerField(default=0, verbose_name='已发布版本号')),
('published_at', models.DateTimeField(blank=True, null=True)),
('updated_by', models.CharField(blank=True, default='', max_length=32)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '角色协议',
'db_table': 'club_role_agreement',
'unique_together': {('club_id', 'role')},
},
),
migrations.CreateModel(
name='ClubRoleAgreementSign',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16)),
('user_id', models.CharField(db_index=True, max_length=16, verbose_name='UserUID')),
('role', models.CharField(db_index=True, max_length=16)),
('agreement_id', models.BigIntegerField(db_index=True, default=0)),
('version', models.PositiveIntegerField(db_index=True)),
('title_snapshot', models.CharField(blank=True, default='', max_length=128)),
('content_snapshot', models.TextField(blank=True, default='')),
('images_snapshot', models.JSONField(blank=True, default=list)),
('sign_image_url', models.CharField(blank=True, default='', max_length=512)),
('client_meta', models.JSONField(blank=True, default=dict)),
('signed_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '角色协议签署记录',
'db_table': 'club_role_agreement_sign',
'unique_together': {('club_id', 'user_id', 'role', 'version')},
},
),
migrations.AddIndex(
model_name='clubroleagreementsign',
index=models.Index(fields=['user_id', 'role', 'version'], name='idx_cras_user_role_ver'),
),
migrations.AddIndex(
model_name='clubroleagreementsign',
index=models.Index(fields=['club_id', 'role', 'signed_at'], name='idx_cras_club_role_t'),
),
]

View File

@@ -0,0 +1,22 @@
# 角色协议:阅读秒数 + 卡点配置(去掉手写依赖,字段兼容保留)
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0018_role_agreement'),
]
operations = [
migrations.AddField(
model_name='clubroleagreement',
name='read_seconds',
field=models.PositiveIntegerField(default=2, verbose_name='阅读秒数'),
),
migrations.AddField(
model_name='clubroleagreement',
name='gate_config',
field=models.JSONField(blank=True, default=dict, verbose_name='签署卡点配置'),
),
]

View File

@@ -464,3 +464,211 @@ class ClubOrderGrabLink(QModel):
models.Index(fields=['grabber_club_id'], name='idx_cogl_grabber'),
models.Index(fields=['order_club_id'], name='idx_cogl_order'),
]
# ==================== 资金冻结(默认关闭,按俱乐部×角色开启)====================
class ClubFundFreezeConfig(QModel):
"""俱乐部×角色冻结规则。enabled=False 时入账行为与现网完全一致。"""
ROLE_CHOICES = (
('dashou', '打手'),
('guanshi', '管事'),
('zuzhang', '组长'),
('shenheguan', '考核官'),
)
FREEZE_MODE_CHOICES = (
(1, '按比例'),
(2, '固定额'),
(3, '比例+单笔上限'),
)
UNFREEZE_TRIGGER_CHOICES = (
('time_only', '仅到期'),
('condition_only', '仅条件达标'),
('time_and_condition', '到期且达标'),
('time_or_condition', '到期或达标'),
('manual_only', '仅手动'),
)
SCHEDULE_MODE_CHOICES = (
('all_at_once', '一次全解'),
('installment', '分期解冻'),
)
club_id = models.CharField(max_length=16, db_index=True, verbose_name='俱乐部')
role = models.CharField(max_length=16, choices=ROLE_CHOICES, db_index=True, verbose_name='角色')
enabled = models.BooleanField(default=False, verbose_name='是否启用冻结')
freeze_mode = models.SmallIntegerField(default=1, choices=FREEZE_MODE_CHOICES)
freeze_rate = models.DecimalField(
max_digits=8, decimal_places=6, default=Decimal('0'),
verbose_name='冻结比例', help_text='0.2=20%',
)
freeze_fixed = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0'))
freeze_cap = models.DecimalField(
max_digits=12, decimal_places=2, default=Decimal('0'),
verbose_name='单笔冻结上限(0=不限)',
)
min_credit_to_freeze = models.DecimalField(
max_digits=12, decimal_places=2, default=Decimal('0'),
verbose_name='低于此入账不冻',
)
source_scope = models.JSONField(
default=list, blank=True,
verbose_name='入账来源白名单',
help_text='空=全部来源;如 ["order_settle"]',
)
unfreeze_trigger = models.CharField(
max_length=32, default='time_only', choices=UNFREEZE_TRIGGER_CHOICES,
)
schedule_mode = models.CharField(
max_length=16, default='all_at_once', choices=SCHEDULE_MODE_CHOICES,
)
installment_json = models.JSONField(
default=dict, blank=True,
verbose_name='分期计划',
help_text='{"steps":[{"seq":1,"after_days":7,"type":"ratio","value":0.3,"need_condition":false}]}',
)
condition_json = models.JSONField(default=dict, blank=True, verbose_name='解冻条件')
freeze_min_days = models.IntegerField(default=0, verbose_name='最短冻结天数')
remark = models.CharField(max_length=255, blank=True, default='')
updated_by = models.CharField(max_length=32, blank=True, default='')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_fund_freeze_config'
verbose_name = '资金冻结规则'
unique_together = [['club_id', 'role']]
class FundFreezeLedger(QModel):
"""单笔入账冻结主单。"""
STATUS_FROZEN = 1
STATUS_PARTIAL = 2
STATUS_DONE = 3
STATUS_VOID = 4
club_id = models.CharField(max_length=16, db_index=True)
user_id = models.CharField(max_length=16, db_index=True, verbose_name='UserUID')
role = models.CharField(max_length=16, db_index=True)
amount_total = models.DecimalField(max_digits=12, decimal_places=2)
amount_unfrozen = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0'))
status = models.SmallIntegerField(default=1, db_index=True)
frozen_at = models.DateTimeField(auto_now_add=True, db_index=True)
source_type = models.CharField(max_length=32, db_index=True)
order_id = models.CharField(max_length=64, blank=True, default='', db_index=True)
czjilu_id = models.CharField(max_length=64, blank=True, default='', db_index=True)
biz_id = models.CharField(max_length=64, verbose_name='幂等业务键')
freeze_reason = models.CharField(max_length=255, blank=True, default='')
config_snapshot = models.JSONField(default=dict, blank=True)
current_step = models.IntegerField(default=1)
next_eligible_at = models.DateTimeField(null=True, blank=True, db_index=True)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'fund_freeze_ledger'
verbose_name = '资金冻结主单'
unique_together = [['club_id', 'role', 'source_type', 'biz_id']]
indexes = [
models.Index(fields=['status', 'next_eligible_at'], name='idx_ffl_status_next'),
models.Index(fields=['user_id', 'role', 'status'], name='idx_ffl_user_role'),
models.Index(fields=['club_id', 'status', 'frozen_at'], name='idx_ffl_club_st'),
]
class FundUnfreezeEvent(QModel):
"""每一次解冻执行记录。"""
ledger_id = models.BigIntegerField(db_index=True)
club_id = models.CharField(max_length=16, db_index=True)
user_id = models.CharField(max_length=16, db_index=True)
role = models.CharField(max_length=16)
step_seq = models.IntegerField(default=1)
amount = models.DecimalField(max_digits=12, decimal_places=2)
unfrozen_at = models.DateTimeField(auto_now_add=True, db_index=True)
trigger = models.CharField(max_length=16, default='schedule')
reason = models.CharField(max_length=255, blank=True, default='')
operator_id = models.CharField(max_length=32, blank=True, default='')
condition_snapshot = models.JSONField(default=dict, blank=True)
class Meta:
db_table = 'fund_unfreeze_event'
verbose_name = '资金解冻事件'
class FundRiskStatDaily(QModel):
"""日汇总:罚款/投诉/完成单等,供解冻条件与后台展示。"""
club_id = models.CharField(max_length=16, db_index=True)
user_id = models.CharField(max_length=16, db_index=True)
role = models.CharField(max_length=16, db_index=True)
day = models.DateField(db_index=True)
completed_orders = models.IntegerField(default=0)
fine_count = models.IntegerField(default=0)
complaint_count = models.IntegerField(default=0)
valid_invite_count = models.IntegerField(default=0)
meta_json = models.JSONField(default=dict, blank=True)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'fund_risk_stat_daily'
verbose_name = '资金风险日汇总'
unique_together = [['club_id', 'user_id', 'role', 'day']]
indexes = [
models.Index(fields=['club_id', 'role', 'day'], name='idx_frsd_club_role_day'),
]
class ClubRoleAgreement(QModel):
"""俱乐部×身份用户协议(当前生效配置,发布时 version+1"""
ROLE_CHOICES = (
('dashou', '打手'),
('guanshi', '管事'),
('zuzhang', '组长'),
)
club_id = models.CharField(max_length=16, db_index=True)
role = models.CharField(max_length=16, choices=ROLE_CHOICES, db_index=True)
enabled = models.BooleanField(default=False, verbose_name='是否要求签署')
title = models.CharField(max_length=128, blank=True, default='', verbose_name='协议标题')
content_text = models.TextField(blank=True, default='', verbose_name='协议正文')
images_json = models.JSONField(default=list, blank=True, verbose_name='说明图片URL列表')
# 勾选前强制停留秒数0=可立即勾选
read_seconds = models.PositiveIntegerField(default=2, verbose_name='阅读秒数')
# 卡点开关qiangdan/register/tixian/invite按角色默认见服务层
gate_config = models.JSONField(default=dict, blank=True, verbose_name='签署卡点配置')
version = models.PositiveIntegerField(default=0, verbose_name='已发布版本号')
published_at = models.DateTimeField(null=True, blank=True)
updated_by = models.CharField(max_length=32, blank=True, default='')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_role_agreement'
verbose_name = '角色协议'
unique_together = [['club_id', 'role']]
class ClubRoleAgreementSign(QModel):
"""用户签署某俱乐部某身份某版本协议的留痕(勾选确认,无手写图)。"""
club_id = models.CharField(max_length=16, db_index=True)
user_id = models.CharField(max_length=16, db_index=True, verbose_name='UserUID')
role = models.CharField(max_length=16, db_index=True)
agreement_id = models.BigIntegerField(db_index=True, default=0)
version = models.PositiveIntegerField(db_index=True)
title_snapshot = models.CharField(max_length=128, blank=True, default='')
content_snapshot = models.TextField(blank=True, default='')
images_snapshot = models.JSONField(default=list, blank=True)
# 兼容旧字段,新签署固定留空
sign_image_url = models.CharField(max_length=512, blank=True, default='')
client_meta = models.JSONField(default=dict, blank=True)
signed_at = models.DateTimeField(auto_now_add=True, db_index=True)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_role_agreement_sign'
verbose_name = '角色协议签署记录'
unique_together = [['club_id', 'user_id', 'role', 'version']]
indexes = [
models.Index(fields=['user_id', 'role', 'version'], name='idx_cras_user_role_ver'),
models.Index(fields=['club_id', 'role', 'signed_at'], name='idx_cras_club_role_t'),
]

View File

@@ -0,0 +1,644 @@
"""资金冻结入账网关与解冻骨架。
默认:无配置或 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),
}

View File

@@ -0,0 +1,421 @@
"""客服后台:资金冻结配置 / 明细 / 风险指标。"""
from __future__ import annotations
import json
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
from django.db.models import Q
from jituan.constants import CLUB_ID_DEFAULT
from jituan.models import ClubFundFreezeConfig, FundFreezeLedger, FundUnfreezeEvent
from jituan.services.club_context import filter_user_related_by_club, resolve_club_id_from_request
from jituan.services.fund_freeze import (
ROLE_DASHOU,
ROLE_GUANSHI,
ROLE_SHENHEGUAN,
ROLE_ZUZHANG,
apply_unfreeze_step,
invalidate_freeze_config_cache,
split_preview,
)
ROLES = (
(ROLE_DASHOU, '打手'),
(ROLE_GUANSHI, '管事'),
(ROLE_ZUZHANG, '组长'),
(ROLE_SHENHEGUAN, '考核官'),
)
DEFAULT_INSTALLMENT = {
'steps': [
{'seq': 1, 'after_days': 7, 'type': 'ratio', 'value': 0.3, 'need_condition': False},
{'seq': 2, 'after_days': 14, 'type': 'ratio', 'value': 0.3, 'need_condition': True},
{'seq': 3, 'after_days': 30, 'type': 'remain', 'value': 1, 'need_condition': True},
]
}
def _club(request) -> str:
return (resolve_club_id_from_request(request) or CLUB_ID_DEFAULT).strip() or CLUB_ID_DEFAULT
def _dec(v, default='0') -> str:
try:
return str(Decimal(str(v if v is not None else default)))
except Exception:
return str(default)
def _cfg_to_dict(row: Optional[ClubFundFreezeConfig], club_id: str, role: str) -> Dict[str, Any]:
if not row:
return {
'club_id': club_id,
'role': role,
'enabled': False,
'freeze_mode': 1,
'freeze_rate': '0',
'freeze_fixed': '0',
'freeze_cap': '0',
'min_credit_to_freeze': '0',
'source_scope': ['order_settle'],
'unfreeze_trigger': 'time_only',
'schedule_mode': 'all_at_once',
'installment_json': {'steps': [{'seq': 1, 'after_days': 15, 'type': 'remain', 'value': 1, 'need_condition': False}]},
'condition_json': {
'min_completed_orders': 0,
'max_fine_rate': 1,
'fine_rate_window_days': 30,
'max_complaint_rate': 1,
'complaint_rate_window_days': 30,
'min_valid_invites': 0,
},
'freeze_min_days': 0,
'remark': '',
'display_reason': '',
'exists': False,
}
return {
'club_id': row.club_id,
'role': row.role,
'enabled': bool(row.enabled),
'freeze_mode': int(row.freeze_mode or 1),
'freeze_rate': _dec(row.freeze_rate),
'freeze_fixed': _dec(row.freeze_fixed),
'freeze_cap': _dec(row.freeze_cap),
'min_credit_to_freeze': _dec(row.min_credit_to_freeze),
'source_scope': row.source_scope or [],
'unfreeze_trigger': row.unfreeze_trigger or 'time_only',
'schedule_mode': row.schedule_mode or 'all_at_once',
'installment_json': row.installment_json or {},
'condition_json': row.condition_json or {},
'freeze_min_days': int(row.freeze_min_days or 0),
'remark': row.remark or '',
'display_reason': row.remark or '',
'updated_by': row.updated_by or '',
'exists': True,
}
def build_config_payload(request) -> Dict[str, Any]:
club_id = _club(request)
rows = {
r.role: r
for r in ClubFundFreezeConfig.query.filter(club_id=club_id)
}
items = []
for role, name in ROLES:
d = _cfg_to_dict(rows.get(role), club_id, role)
d['role_name'] = name
avail, frozen = split_preview(club_id, role, Decimal('100'), 'order_settle')
d['preview_100'] = {'available': str(avail), 'frozen': str(frozen)}
items.append(d)
return {'club_id': club_id, 'roles': items}
def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict], Optional[str]]:
club_id = _club(request)
role = (data.get('role') or '').strip().lower()
if role not in {r[0] for r in ROLES}:
return None, '无效角色'
enabled = bool(data.get('enabled'))
freeze_mode = int(data.get('freeze_mode') or 1)
if freeze_mode not in (1, 2, 3):
return None, 'freeze_mode 无效'
try:
freeze_rate = Decimal(str(data.get('freeze_rate') or 0))
freeze_fixed = Decimal(str(data.get('freeze_fixed') or 0))
freeze_cap = Decimal(str(data.get('freeze_cap') or 0))
min_credit = Decimal(str(data.get('min_credit_to_freeze') or 0))
except Exception:
return None, '金额/比例格式错误'
if freeze_rate < 0 or freeze_rate > 1:
return None, '冻结比例须在 0~1 之间(如 0.2=20%'
source_scope = data.get('source_scope')
if source_scope is None:
source_scope = ['order_settle']
if isinstance(source_scope, str):
try:
source_scope = json.loads(source_scope)
except Exception:
source_scope = [source_scope]
unfreeze_trigger = (data.get('unfreeze_trigger') or 'time_only').strip()
if unfreeze_trigger not in (
'time_only', 'condition_only', 'time_and_condition', 'time_or_condition', 'manual_only',
):
return None, '解冻触发模式无效'
schedule_mode = (data.get('schedule_mode') or 'all_at_once').strip()
if schedule_mode not in ('all_at_once', 'installment'):
return None, '分期模式无效'
installment_json = data.get('installment_json') or {}
if isinstance(installment_json, str):
try:
installment_json = json.loads(installment_json)
except Exception:
return None, 'installment_json 不是合法 JSON'
if schedule_mode == 'installment':
steps = installment_json.get('steps') if isinstance(installment_json, dict) else None
if not steps:
installment_json = dict(DEFAULT_INSTALLMENT)
steps = installment_json['steps']
# 规范化:自由添加的分期重新编号,校验类型
normalized = []
for i, s in enumerate(steps):
if not isinstance(s, dict):
return None, f'{i + 1}期格式错误'
typ = (s.get('type') or 'ratio').strip().lower()
if typ not in ('ratio', 'fixed', 'remain'):
return None, f'{i + 1}期类型无效ratio/fixed/remain'
try:
after_days = max(0, int(s.get('after_days') or 0))
except (TypeError, ValueError):
return None, f'{i + 1}期天数无效'
try:
value = float(s.get('value') or 0)
except (TypeError, ValueError):
return None, f'{i + 1}期数值无效'
if typ == 'ratio' and (value < 0 or value > 1):
return None, f'{i + 1}期比例须在 0~1'
normalized.append({
'seq': i + 1,
'after_days': after_days,
'type': typ,
'value': value if typ != 'remain' else 1,
'need_condition': bool(s.get('need_condition')),
})
if not normalized:
return None, '请至少添加一期分期'
# 最后一期建议 remain若全是 ratio 且合计偏离太大仅告警不拦截——尾差由 remain 或末步吃完
installment_json = {'steps': normalized}
condition_json = data.get('condition_json') or {}
if isinstance(condition_json, str):
try:
condition_json = json.loads(condition_json)
except Exception:
return None, 'condition_json 不是合法 JSON'
freeze_min_days = int(data.get('freeze_min_days') or 0)
if freeze_min_days < 0:
return None, '最短冻结天数不能为负'
# 用户可见冻结说明:优先 display_reason兼容 remark
display_reason = (data.get('display_reason') or data.get('remark') or '')[:255]
row = ClubFundFreezeConfig.query.filter(club_id=club_id, role=role).first()
if not row:
row = ClubFundFreezeConfig(
club_id=club_id,
role=role,
)
row.enabled = enabled
row.freeze_mode = freeze_mode
row.freeze_rate = freeze_rate
row.freeze_fixed = freeze_fixed
row.freeze_cap = freeze_cap
row.min_credit_to_freeze = min_credit
row.source_scope = source_scope
row.unfreeze_trigger = unfreeze_trigger
row.schedule_mode = schedule_mode
row.installment_json = installment_json
row.condition_json = condition_json
row.freeze_min_days = freeze_min_days
row.remark = display_reason
row.updated_by = (operator or '')[:32]
row.save()
invalidate_freeze_config_cache(club_id, role)
out = _cfg_to_dict(row, club_id, role)
out['role_name'] = dict(ROLES).get(role, role)
return out, None
STATUS_LABEL = {
FundFreezeLedger.STATUS_FROZEN: '冻结中',
FundFreezeLedger.STATUS_PARTIAL: '部分解冻',
FundFreezeLedger.STATUS_DONE: '已解冻',
FundFreezeLedger.STATUS_VOID: '已作废',
}
def list_ledgers(request, data: dict) -> Dict[str, Any]:
club_id = _club(request)
page = max(1, int(data.get('page') or 1))
page_size = min(100, max(1, int(data.get('page_size') or 20)))
role = (data.get('role') or '').strip().lower()
status = data.get('status')
user_id = (data.get('user_id') or data.get('yonghuid') or '').strip()
order_id = (data.get('order_id') or '').strip()
kw = (data.get('keyword') or '').strip()
qs = FundFreezeLedger.query.filter(club_id=club_id)
if role:
qs = qs.filter(role=role)
if status not in (None, ''):
try:
qs = qs.filter(status=int(status))
except (TypeError, ValueError):
pass
if user_id:
qs = qs.filter(user_id=user_id)
if order_id:
qs = qs.filter(order_id=order_id)
if kw:
qs = qs.filter(Q(user_id__icontains=kw) | Q(order_id__icontains=kw) | Q(biz_id__icontains=kw))
total = qs.count()
rows = qs.order_by('-id')[(page - 1) * page_size: page * page_size]
items = []
for r in rows:
remain = Decimal(str(r.amount_total or 0)) - Decimal(str(r.amount_unfrozen or 0))
items.append({
'id': r.id,
'club_id': r.club_id,
'user_id': r.user_id,
'role': r.role,
'role_name': dict(ROLES).get(r.role, r.role),
'amount_total': _dec(r.amount_total),
'amount_unfrozen': _dec(r.amount_unfrozen),
'amount_remain': _dec(remain),
'status': r.status,
'status_label': STATUS_LABEL.get(r.status, str(r.status)),
'frozen_at': r.frozen_at.isoformat() if r.frozen_at else '',
'next_eligible_at': r.next_eligible_at.isoformat() if r.next_eligible_at else '',
'source_type': r.source_type,
'order_id': r.order_id,
'czjilu_id': r.czjilu_id,
'biz_id': r.biz_id,
'freeze_reason': r.freeze_reason,
'current_step': r.current_step,
})
return {
'club_id': club_id,
'list': items,
'total': total,
'page': page,
'page_size': page_size,
}
def list_unfreeze_events(ledger_id: int, club_id: str) -> List[dict]:
qs = FundUnfreezeEvent.query.filter(ledger_id=ledger_id, club_id=club_id).order_by('-id')[:50]
return [{
'id': e.id,
'step_seq': e.step_seq,
'amount': _dec(e.amount),
'unfrozen_at': e.unfrozen_at.isoformat() if e.unfrozen_at else '',
'trigger': e.trigger,
'reason': e.reason,
'operator_id': e.operator_id,
} for e in qs]
def manual_unfreeze(request, data: dict, operator: str = '') -> Tuple[Optional[dict], Optional[str]]:
club_id = _club(request)
try:
ledger_id = int(data.get('ledger_id') or data.get('id') or 0)
except (TypeError, ValueError):
return None, 'ledger_id 无效'
if ledger_id <= 0:
return None, '缺少 ledger_id'
ledger = FundFreezeLedger.query.filter(id=ledger_id, club_id=club_id).first()
if not ledger:
return None, '冻结单不存在或不属于当前俱乐部'
mode = (data.get('mode') or 'step').strip() # step | all
if mode == 'all':
# 循环解完(最多 20 步防死循环)
last = None
for _ in range(20):
last = apply_unfreeze_step(
ledger_id,
trigger='manual',
operator_id=operator,
reason=(data.get('reason') or '后台手动全部解冻')[:255],
force=True,
)
if not last.get('ok'):
return None, last.get('msg') or '解冻失败'
if last.get('done'):
break
return last, None
result = apply_unfreeze_step(
ledger_id,
trigger='manual',
operator_id=operator,
reason=(data.get('reason') or '后台手动解冻一步')[:255],
force=True,
)
if not result.get('ok'):
return None, result.get('msg') or '解冻失败'
return result, None
def list_risk_users(request, data: dict) -> Dict[str, Any]:
"""从角色扩展表读风险快照 + 冻结池(按俱乐部用户过滤)。"""
from users.models import UserDashou, UserGuanshi, UserZuzhang, UserShenheguan
from jituan.services.club_context import filter_user_related_by_club
club_id = _club(request)
role = (data.get('role') or ROLE_DASHOU).strip().lower()
page = max(1, int(data.get('page') or 1))
page_size = min(100, max(1, int(data.get('page_size') or 20)))
keyword = (data.get('keyword') or '').strip()
only_frozen = bool(data.get('only_frozen'))
mapping = {
ROLE_DASHOU: UserDashou,
ROLE_GUANSHI: UserGuanshi,
ROLE_ZUZHANG: UserZuzhang,
ROLE_SHENHEGUAN: UserShenheguan,
}
model = mapping.get(role)
if not model:
return {'club_id': club_id, 'list': [], 'total': 0, 'page': page, 'page_size': page_size}
qs = model.query.select_related('user').all()
qs = filter_user_related_by_club(qs, request, user_club_path='user__ClubID')
if keyword:
qs = qs.filter(user__UserUID__icontains=keyword)
if only_frozen:
qs = qs.filter(dongjie_yue__gt=0)
total = qs.count()
rows = qs.order_by('-dongjie_yue', '-id')[(page - 1) * page_size: page * page_size]
items = []
for p in rows:
uid = getattr(p.user, 'UserUID', '') if p.user_id else ''
if role == ROLE_ZUZHANG:
available = _dec(getattr(p, 'ketixian_jine', 0))
else:
available = _dec(getattr(p, 'yue', 0))
items.append({
'user_id': uid,
'role': role,
'role_name': dict(ROLES).get(role, role),
'available': available,
'dongjie_yue': _dec(getattr(p, 'dongjie_yue', 0)),
'risk_fine_rate': _dec(getattr(p, 'risk_fine_rate', 0)),
'risk_complaint_rate': _dec(getattr(p, 'risk_complaint_rate', 0)),
'risk_completed_orders': int(getattr(p, 'risk_completed_orders', 0) or 0),
'risk_valid_invites': int(getattr(p, 'risk_valid_invites', 0) or 0),
'risk_stat_at': p.risk_stat_at.isoformat() if getattr(p, 'risk_stat_at', None) else '',
'nicheng': getattr(p, 'nicheng', '') or '',
})
return {
'club_id': club_id,
'role': role,
'list': items,
'total': total,
'page': page,
'page_size': page_size,
}

View File

@@ -0,0 +1,250 @@
"""资金冻结:解冻条件评估(实时算窗口指标,并回写角色 risk_* 快照)。"""
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.utils import timezone
logger = logging.getLogger(__name__)
def _dec(v, default='0') -> Decimal:
try:
return Decimal(str(v if v is not None else default)).quantize(
Decimal('0.000001'), rounding=ROUND_HALF_UP
)
except Exception:
return Decimal(default)
def _int(v, default=0) -> int:
try:
return int(v)
except (TypeError, ValueError):
return default
def _flag(cfg: dict, enable_key: str, legacy_active: bool) -> bool:
"""新配置用 enable_*;旧配置无 enable 时按 legacy_active 推断。"""
if enable_key in cfg:
return bool(cfg.get(enable_key))
return legacy_active
def compute_risk_metrics(
club_id: str,
user_id: str,
role: str,
condition_json: Optional[dict] = None,
) -> Dict[str, Any]:
"""按窗口统计完成单量 / 罚款率 / 投诉率 / 有效邀请。失败时返回安全默认。"""
cfg = condition_json or {}
fine_days = max(1, _int(cfg.get('fine_rate_window_days'), 30))
complaint_days = max(1, _int(cfg.get('complaint_rate_window_days'), 30))
order_days = max(fine_days, complaint_days, _int(cfg.get('order_window_days'), 30))
now = timezone.now()
since_order = now - timedelta(days=order_days)
since_fine = now - timedelta(days=fine_days)
since_complaint = now - timedelta(days=complaint_days)
completed = 0
fine_count = 0
complaint_count = 0
valid_invites = 0
try:
from orders.models import Order, Penalty
if role == 'dashou':
completed = Order.query.filter(
ClubID=club_id,
PlayerID=user_id,
Status=3,
CreateTime__gte=since_order,
).count()
fine_count = Penalty.query.filter(
PenalizedUserID=user_id,
Identity=1,
Status__in=[1, 2, 3, 5],
CreateTime__gte=since_fine,
).count()
# 投诉:窗口内该打手完成单关联的工单数
try:
from gongdan.models import Gongdan
order_ids = list(
Order.query.filter(
ClubID=club_id,
PlayerID=user_id,
Status=3,
CreateTime__gte=since_complaint,
).values_list('OrderID', flat=True)[:800]
)
if order_ids:
complaint_count = Gongdan.query.filter(
order_id__in=order_ids,
).count()
except Exception as e:
logger.warning('complaint metrics skip: %s', e)
valid_invites = 0
elif role == 'guanshi':
from users.models import User, UserGuanshi, UserDashou
u = User.query.filter(UserUID=user_id).first()
gs = UserGuanshi.query.filter(user=u).first() if u else None
if gs is not None:
valid_invites = int(getattr(gs, 'yaogingshuliang', 0) or 0)
# 管事「完成单」近似:窗口内其邀请打手的成交单量
dashou_uids = list(
UserDashou.query.filter(yaoqingren=user_id).values_list(
'user__UserUID', flat=True
)[:500]
)
if dashou_uids:
completed = Order.query.filter(
ClubID=club_id,
PlayerID__in=dashou_uids,
Status=3,
CreateTime__gte=since_order,
).count()
fine_count = Penalty.query.filter(
PenalizedUserID=user_id,
Identity=2,
Status__in=[1, 2, 3, 5],
CreateTime__gte=since_fine,
).count()
try:
from gongdan.models import Gongdan
complaint_count = Gongdan.query.filter(
guanshi_id=user_id,
CreateTime__gte=since_complaint,
).count()
except Exception:
pass
elif role == 'zuzhang':
from users.models import User, UserGuanshi, UserZuzhang
u = User.query.filter(UserUID=user_id).first()
zz = UserZuzhang.query.filter(user=u).first() if u else None
if zz is not None:
valid_invites = UserGuanshi.query.filter(yaoqingren=user_id).count()
fine_count = Penalty.query.filter(
PenalizedUserID=user_id,
Identity=4,
Status__in=[1, 2, 3, 5],
CreateTime__gte=since_fine,
).count()
completed = valid_invites # 组长无直接结单,用邀请管事数作「完成」近似时可被 min_orders 覆盖
else: # shenheguan
fine_count = Penalty.query.filter(
PenalizedUserID=user_id,
Status__in=[1, 2, 3, 5],
CreateTime__gte=since_fine,
).count()
except Exception as e:
logger.exception('compute_risk_metrics failed user=%s role=%s: %s', user_id, role, e)
denom = max(completed, 1)
fine_rate = _dec(fine_count) / _dec(denom)
complaint_rate = _dec(complaint_count) / _dec(denom)
return {
'completed_orders': completed,
'fine_count': fine_count,
'complaint_count': complaint_count,
'fine_rate': str(fine_rate),
'complaint_rate': str(complaint_rate),
'valid_invites': valid_invites,
'order_window_days': order_days,
'fine_rate_window_days': fine_days,
'complaint_rate_window_days': complaint_days,
}
def write_risk_snapshot(profile, metrics: dict) -> None:
if profile is None:
return
try:
profile.__class__.query.filter(pk=profile.pk).update(
risk_fine_rate=_dec(metrics.get('fine_rate')),
risk_complaint_rate=_dec(metrics.get('complaint_rate')),
risk_completed_orders=_int(metrics.get('completed_orders')),
risk_valid_invites=_int(metrics.get('valid_invites')),
risk_stat_at=timezone.now(),
)
except Exception as e:
logger.warning('write_risk_snapshot failed: %s', e)
def evaluate_unfreeze_conditions(
club_id: str,
user_id: str,
role: str,
condition_json: Optional[dict],
profile=None,
) -> Tuple[bool, Dict[str, Any]]:
"""
返回 (是否达标, 指标快照)。
未勾选的条件不参与;全部未勾选视为「无条件限制」→ 达标。
"""
cfg = condition_json or {}
metrics = compute_risk_metrics(club_id, user_id, role, cfg)
write_risk_snapshot(profile, metrics)
checks = []
reasons = []
# 最低完成单量
min_orders = _int(cfg.get('min_completed_orders'), 0)
if _flag(cfg, 'enable_min_completed_orders', min_orders > 0):
ok = metrics['completed_orders'] >= min_orders
checks.append(ok)
if not ok:
reasons.append(
f"完成单量 {metrics['completed_orders']}/{min_orders}"
)
# 最高罚款率
max_fine = _dec(cfg.get('max_fine_rate'), '1')
if _flag(cfg, 'enable_max_fine_rate', 0 <= max_fine < 1):
ok = _dec(metrics['fine_rate']) <= max_fine
checks.append(ok)
if not ok:
reasons.append(
f"罚款率 {metrics['fine_rate']} > {max_fine}"
)
# 最高投诉率
max_comp = _dec(cfg.get('max_complaint_rate'), '1')
if _flag(cfg, 'enable_max_complaint_rate', 0 <= max_comp < 1):
ok = _dec(metrics['complaint_rate']) <= max_comp
checks.append(ok)
if not ok:
reasons.append(
f"投诉率 {metrics['complaint_rate']} > {max_comp}"
)
# 最低有效邀请
min_inv = _int(cfg.get('min_valid_invites'), 0)
if _flag(cfg, 'enable_min_valid_invites', min_inv > 0):
ok = metrics['valid_invites'] >= min_inv
checks.append(ok)
if not ok:
reasons.append(
f"有效邀请 {metrics['valid_invites']}/{min_inv}"
)
if not checks:
passed = True
else:
passed = all(checks)
snap = {
**metrics,
'passed': passed,
'fail_reasons': reasons,
}
return passed, snap

View File

@@ -6,6 +6,12 @@ KEFU_MENU_ROW_DEFS = [
'perm_codes': ['bankuai']},
{'page_id': 'finance.data', 'name': '财务数据', 'path': '/finance/data', 'parent_id': 'finance', 'sort_order': 12,
'perm_codes': ['caiwu']},
{'page_id': 'finance.fund-freeze-config', 'name': '资金冻结规则', 'path': '/finance/fund-freeze-config',
'parent_id': 'finance', 'sort_order': 13, 'perm_codes': ['djcfg001']},
{'page_id': 'finance.fund-freeze-ledger', 'name': '资金冻结明细', 'path': '/finance/fund-freeze-ledger',
'parent_id': 'finance', 'sort_order': 14, 'perm_codes': ['djled001']},
{'page_id': 'finance.fund-risk-stats', 'name': '罚款投诉指标', 'path': '/finance/fund-risk-stats',
'parent_id': 'finance', 'sort_order': 15, 'perm_codes': ['djrisk001']},
{'page_id': 'assessment', 'name': '考核管理', 'path': '', 'parent_id': '', 'sort_order': 20},
{'page_id': 'assessment.examiner', 'name': '考核官管理', 'path': '/assessment/examiner', 'parent_id': 'assessment', 'sort_order': 21,
'perm_codes': ['kaohepeizhi']},
@@ -101,6 +107,8 @@ KEFU_MENU_ROW_DEFS = [
'perm_codes': ['phbj666', '8080a']},
{'page_id': 'miniapp.recharge-copy', 'name': '充值页文案', 'path': '/miniapp/recharge-copy', 'parent_id': 'miniapp', 'sort_order': 119,
'perm_codes': ['8080a']},
{'page_id': 'miniapp.role-agreement', 'name': '角色协议', 'path': '/miniapp/role-agreement', 'parent_id': 'miniapp', 'sort_order': 120,
'perm_codes': ['xycfg001', '8080a']},
{'page_id': 'shop', 'name': '店铺管理', 'path': '', 'parent_id': '', 'sort_order': 120},
{'page_id': 'shop.list', 'name': '店铺列表', 'path': '/shop/list', 'parent_id': 'shop', 'sort_order': 121,
'perm_codes': ['999a', '999b', '999c', '999d', '999e']},

View File

@@ -0,0 +1,355 @@
"""角色协议:后台配置 + 小程序勾选签署(按俱乐部隔离)。"""
from __future__ import annotations
import uuid
from typing import Any, Dict, List, Optional, Tuple
from django.utils import timezone
from jituan.constants import CLUB_ID_DEFAULT
from jituan.models import ClubRoleAgreement, ClubRoleAgreementSign
from jituan.services.club_context import resolve_club_id_from_request
ROLES = (
('dashou', '打手/接单员'),
('guanshi', '管事'),
('zuzhang', '组长'),
)
ROLE_SET = {r[0] for r in ROLES}
# 卡点:可后台逐项开关;默认贴近产品约定
GATE_DEFS = (
('qiangdan', '抢单/接单时'),
('register', '身份注册时'),
('tixian', '提现时'),
('invite', '邀请下级时'),
)
GATE_SET = {g[0] for g in GATE_DEFS}
DEFAULT_GATES = {
'dashou': {'qiangdan': True, 'register': False, 'tixian': False, 'invite': False},
'guanshi': {'qiangdan': False, 'register': True, 'tixian': True, 'invite': False},
'zuzhang': {'qiangdan': False, 'register': False, 'tixian': True, 'invite': False},
}
def _club(request) -> str:
return (resolve_club_id_from_request(request) or CLUB_ID_DEFAULT).strip() or CLUB_ID_DEFAULT
def _images(val) -> List[str]:
if not val:
return []
if isinstance(val, list):
return [str(x).strip() for x in val if str(x).strip()][:20]
return []
def _clamp_read_seconds(val) -> int:
try:
n = int(val)
except (TypeError, ValueError):
n = 2
if n < 0:
n = 0
if n > 300:
n = 300
return n
def normalize_gates(role: str, raw) -> Dict[str, bool]:
base = dict(DEFAULT_GATES.get(role) or {k: False for k in GATE_SET})
if isinstance(raw, dict):
for k in GATE_SET:
if k in raw:
base[k] = bool(raw.get(k))
return base
def _row_to_dict(row: Optional[ClubRoleAgreement], club_id: str, role: str) -> Dict[str, Any]:
if not row:
return {
'club_id': club_id,
'role': role,
'enabled': False,
'title': '',
'content_text': '',
'images': [],
'read_seconds': 2,
'gate_config': normalize_gates(role, None),
'gate_defs': [{'key': k, 'label': lab} for k, lab in GATE_DEFS],
'version': 0,
'published_at': '',
'exists': False,
}
return {
'club_id': row.club_id,
'role': row.role,
'enabled': bool(row.enabled),
'title': row.title or '',
'content_text': row.content_text or '',
'images': _images(row.images_json),
'read_seconds': _clamp_read_seconds(getattr(row, 'read_seconds', 2)),
'gate_config': normalize_gates(role, getattr(row, 'gate_config', None)),
'gate_defs': [{'key': k, 'label': lab} for k, lab in GATE_DEFS],
'version': int(row.version or 0),
'published_at': row.published_at.isoformat() if row.published_at else '',
'updated_by': row.updated_by or '',
'exists': True,
'id': row.id,
}
def build_admin_payload(request) -> Dict[str, Any]:
club_id = _club(request)
rows = {
r.role: r
for r in ClubRoleAgreement.query.filter(club_id=club_id)
}
items = []
for role, name in ROLES:
d = _row_to_dict(rows.get(role), club_id, role)
d['role_name'] = name
items.append(d)
return {
'club_id': club_id,
'roles': items,
'gate_defs': [{'key': k, 'label': lab} for k, lab in GATE_DEFS],
}
def save_agreement(request, data: dict, operator: str = '') -> Tuple[Optional[dict], Optional[str]]:
club_id = _club(request)
role = (data.get('role') or '').strip().lower()
if role not in ROLE_SET:
return None, '无效角色'
title = (data.get('title') or '')[:128]
content_text = data.get('content_text') or ''
if len(content_text) > 50000:
return None, '正文过长'
images = _images(data.get('images') or data.get('images_json'))
enabled = bool(data.get('enabled'))
read_seconds = _clamp_read_seconds(data.get('read_seconds', 2))
gate_config = normalize_gates(role, data.get('gate_config'))
row = ClubRoleAgreement.query.filter(club_id=club_id, role=role).first()
if not row:
row = ClubRoleAgreement(club_id=club_id, role=role, version=0)
row.enabled = enabled
row.title = title
row.content_text = content_text
row.images_json = images
row.read_seconds = read_seconds
row.gate_config = gate_config
row.updated_by = (operator or '')[:32]
row.save()
out = _row_to_dict(row, club_id, role)
out['role_name'] = dict(ROLES).get(role, role)
return out, None
def publish_agreement(request, data: dict, operator: str = '') -> Tuple[Optional[dict], Optional[str]]:
"""保存内容并 version+1强制用户重签新版本。"""
club_id = _club(request)
role = (data.get('role') or '').strip().lower()
if role not in ROLE_SET:
return None, '无效角色'
saved, err = save_agreement(request, data, operator=operator)
if err:
return None, err
row = ClubRoleAgreement.query.filter(club_id=club_id, role=role).first()
if not row:
return None, '协议不存在'
if not (row.title or '').strip() and not (row.content_text or '').strip() and not _images(row.images_json):
return None, '请先填写标题、正文或上传图片再发布'
row.version = int(row.version or 0) + 1
row.published_at = timezone.now()
row.enabled = True if data.get('enabled') is None else bool(data.get('enabled'))
row.updated_by = (operator or '')[:32]
row.save()
out = _row_to_dict(row, club_id, role)
out['role_name'] = dict(ROLES).get(role, role)
return out, None
def get_current_for_user(
club_id: str,
user_id: str,
role: str,
*,
gate: str = '',
view_only: bool = False,
) -> Dict[str, Any]:
"""
取某角色当前协议。
- gate 非空:仅当该卡点开启且未签当前版时 need_sign=True
- view_only只读查看不强制签署
"""
role = (role or '').strip().lower()
if role not in ROLE_SET:
return {'need_sign': False, 'enabled': False, 'msg': '无效角色'}
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
row = ClubRoleAgreement.query.filter(club_id=cid, role=role).first()
empty = {
'need_sign': False,
'enabled': False,
'club_id': cid,
'role': role,
'version': 0,
'title': '',
'content_text': '',
'images': [],
'read_seconds': 0,
'gate_config': normalize_gates(role, None),
'gate_active': False,
'already_signed': False,
}
if not row or int(row.version or 0) <= 0:
return empty
version = int(row.version)
gates = normalize_gates(role, getattr(row, 'gate_config', None))
signed = ClubRoleAgreementSign.query.filter(
club_id=cid, user_id=str(user_id), role=role, version=version,
).exists()
enabled = bool(row.enabled)
read_seconds = _clamp_read_seconds(getattr(row, 'read_seconds', 2))
gate_key = (gate or '').strip().lower()
gate_active = True
if gate_key:
if gate_key not in GATE_SET:
return {**empty, 'msg': '无效卡点', 'enabled': enabled}
gate_active = bool(gates.get(gate_key))
need_sign = False
if enabled and not view_only and not signed and gate_active:
need_sign = True
# 无 gate 且非 view兼容旧调用只要启用且未签就 need_sign
if enabled and not view_only and not signed and not gate_key:
need_sign = True
return {
'need_sign': need_sign,
'enabled': enabled,
'club_id': cid,
'role': role,
'role_name': dict(ROLES).get(role, role),
'version': version,
'title': row.title or '角色服务协议',
'content_text': row.content_text or '',
'images': _images(row.images_json),
'read_seconds': read_seconds,
'gate_config': gates,
'gate': gate_key,
'gate_active': gate_active if gate_key else enabled,
'agreement_id': row.id,
'published_at': row.published_at.isoformat() if row.published_at else '',
'already_signed': signed,
}
def list_for_user(club_id: str, user_id: str, roles: Optional[List[str]] = None) -> Dict[str, Any]:
"""小程序「我的协议」列表:按俱乐部返回已发布协议摘要。"""
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
want = [r for r in (roles or list(ROLE_SET)) if r in ROLE_SET]
if not want:
want = list(ROLE_SET)
items = []
for role in want:
d = get_current_for_user(cid, user_id, role, view_only=True)
if int(d.get('version') or 0) <= 0:
continue
items.append({
'role': d['role'],
'role_name': d.get('role_name') or role,
'title': d.get('title') or '',
'version': d.get('version') or 0,
'already_signed': bool(d.get('already_signed')),
'enabled': bool(d.get('enabled')),
'published_at': d.get('published_at') or '',
})
return {'club_id': cid, 'items': items}
def sign_agreement(
club_id: str,
user_id: str,
role: str,
*,
client_meta: Optional[dict] = None,
agreed: bool = False,
) -> Tuple[Optional[dict], Optional[str]]:
"""勾选确认签署;不再要求手写签名图。"""
role = (role or '').strip().lower()
if role not in ROLE_SET:
return None, '无效角色'
if not agreed:
return None, '请勾选同意协议'
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
row = ClubRoleAgreement.query.filter(club_id=cid, role=role).first()
if not row or not row.enabled or int(row.version or 0) <= 0:
return None, '当前无需签署或协议未发布'
version = int(row.version)
existed = ClubRoleAgreementSign.query.filter(
club_id=cid, user_id=str(user_id), role=role, version=version,
).first()
if existed:
return {
'already_signed': True,
'sign_id': existed.id,
'version': version,
'signed_at': existed.signed_at.isoformat() if existed.signed_at else '',
}, None
meta = dict(client_meta or {})
meta.setdefault('sign_mode', 'checkbox')
sign = ClubRoleAgreementSign.query.create(
club_id=cid,
user_id=str(user_id),
role=role,
agreement_id=row.id,
version=version,
title_snapshot=(row.title or '')[:128],
content_snapshot=row.content_text or '',
images_snapshot=_images(row.images_json),
sign_image_url='',
client_meta=meta,
)
return {
'already_signed': False,
'sign_id': sign.id,
'version': version,
'signed_at': sign.signed_at.isoformat() if sign.signed_at else '',
}, None
def upload_agreement_image(file_obj, club_id: str, kind: str = 'content') -> Tuple[Optional[str], Optional[str]]:
"""后台上传协议配图,返回相对路径。"""
from utils.oss_utils import validate_image, upload_to_oss
ok, err = validate_image(file_obj)
if not ok:
return None, err or '图片无效'
cid = (club_id or 'xq').strip() or 'xq'
kind = 'content'
ext = 'png'
name = getattr(file_obj, 'name', '') or ''
if '.' in name:
ext = name.rsplit('.', 1)[-1].lower()[:8] or 'png'
key = f'role_agreement/{cid}/{kind}/{uuid.uuid4().hex}.{ext}'
try:
path = upload_to_oss(file_obj, key)
if not path:
return None, '上传失败'
except Exception as e:
return None, f'上传失败: {e}'
return path, None

15
jituan/tasks.py Normal file
View File

@@ -0,0 +1,15 @@
"""资金冻结相关 Celery 定时任务。"""
import logging
from celery import shared_task
logger = logging.getLogger(__name__)
@shared_task(name='jituan.tasks.scan_fund_freeze_due')
def scan_fund_freeze_due(limit: int = 200):
"""扫到期冻结单,按配置执行一步解冻(未开冻结的俱乐部无数据,空跑)。"""
from jituan.services.fund_freeze import scan_due_freeze_ledgers
result = scan_due_freeze_ledgers(limit=limit)
logger.info('scan_fund_freeze_due %s', result)
return result

View File

@@ -101,6 +101,12 @@ from jituan.views_recharge_page_copy import (
RechargePageCopyView,
)
from jituan.views_order_grab_share import ClubOrderGrabLinkManageView
from jituan.views_fund_freeze import (
FundFreezeConfigView,
FundFreezeLedgerView,
FundFreezeRiskView,
)
from jituan.views_role_agreement import RoleAgreementAdminView
urlpatterns = [
path('auth/wechat-login', ClubWechatLoginView.as_view(), name='jituan_wechat_login'),
@@ -112,6 +118,10 @@ urlpatterns = [
path('auth/dashou-register', ClubDashouRegisterView.as_view(), name='jituan_dashou_register'),
path('houtai/club-manage', ClubManageView.as_view(), name='jituan_club_manage'),
path('houtai/order-grab-link', ClubOrderGrabLinkManageView.as_view(), name='jituan_order_grab_link'),
path('houtai/fund-freeze-config', FundFreezeConfigView.as_view(), name='jituan_fund_freeze_config'),
path('houtai/fund-freeze-ledger', FundFreezeLedgerView.as_view(), name='jituan_fund_freeze_ledger'),
path('houtai/fund-freeze-risk', FundFreezeRiskView.as_view(), name='jituan_fund_freeze_risk'),
path('houtai/role-agreement', RoleAgreementAdminView.as_view(), name='jituan_role_agreement'),
path('houtai/payment-channel-manage', ClubPaymentChannelManageView.as_view(), name='jituan_payment_channel_manage'),
path('houtai/club-cert-upload', ClubCertUploadView.as_view(), name='jituan_club_cert_upload'),
path('houtai/admin-assignments', ClubAdminAssignmentListView.as_view(), name='jituan_admin_assignments'),

105
jituan/views_fund_freeze.py Normal file
View File

@@ -0,0 +1,105 @@
"""客服后台资金冻结 API。"""
from rest_framework.parsers import JSONParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from backend.utils import verify_kefu_permission
from jituan.services import fund_freeze_admin as admin_svc
PERM_CFG = 'djcfg001'
PERM_LED = 'djled001'
PERM_RISK = 'djrisk001'
def _auth(request, perm_code: str):
username = (request.data.get('username') or request.data.get('phone') or '').strip()
if not username:
return None, None, Response({'code': 400, 'msg': '缺少username'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return None, None, permissions
codes = set(permissions or [])
user = getattr(request, 'user', None)
if getattr(user, 'IsSuperuser', False) or '000001' in codes:
return kefu, permissions, None
if perm_code not in codes:
return None, None, Response({'code': 403, 'msg': f'无权限(需要 {perm_code}'})
return kefu, permissions, None
class FundFreezeConfigView(APIView):
"""
POST /jituan/houtai/fund-freeze-config
action: get | save
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
kefu, permissions, err = _auth(request, PERM_CFG)
if err:
return err
action = (request.data.get('action') or 'get').strip().lower()
if action == 'get':
return Response({'code': 0, 'msg': 'ok', 'data': admin_svc.build_config_payload(request)})
if action == 'save':
operator = getattr(getattr(request, 'user', None), 'UserUID', '') or ''
data, msg = admin_svc.save_config(request, request.data, operator=operator)
if msg:
return Response({'code': 400, 'msg': msg})
return Response({'code': 0, 'msg': '已保存', 'data': data})
return Response({'code': 400, 'msg': '无效 action'})
class FundFreezeLedgerView(APIView):
"""
POST /jituan/houtai/fund-freeze-ledger
action: list | events | unfreeze
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
kefu, permissions, err = _auth(request, PERM_LED)
if err:
return err
action = (request.data.get('action') or 'list').strip().lower()
if action == 'list':
return Response({'code': 0, 'msg': 'ok', 'data': admin_svc.list_ledgers(request, request.data)})
if action == 'events':
from jituan.services.fund_freeze_admin import _club
try:
lid = int(request.data.get('ledger_id') or 0)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': 'ledger_id 无效'})
return Response({
'code': 0,
'msg': 'ok',
'data': {'list': admin_svc.list_unfreeze_events(lid, _club(request))},
})
if action == 'unfreeze':
operator = getattr(getattr(request, 'user', None), 'UserUID', '') or ''
data, msg = admin_svc.manual_unfreeze(request, request.data, operator=operator)
if msg:
return Response({'code': 400, 'msg': msg})
return Response({'code': 0, 'msg': '解冻成功', 'data': data})
return Response({'code': 400, 'msg': '无效 action'})
class FundFreezeRiskView(APIView):
"""
POST /jituan/houtai/fund-freeze-risk
action: list
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
kefu, permissions, err = _auth(request, PERM_RISK)
if err:
return err
action = (request.data.get('action') or 'list').strip().lower()
if action == 'list':
return Response({'code': 0, 'msg': 'ok', 'data': admin_svc.list_risk_users(request, request.data)})
return Response({'code': 400, 'msg': '无效 action'})

View File

@@ -0,0 +1,70 @@
"""客服后台:角色协议配置。"""
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from backend.utils import verify_kefu_permission
from jituan.services import role_agreement as svc
from jituan.services.club_context import resolve_club_id_from_request
PERM_CODE = 'xycfg001'
def _auth(request, perm_code: str = PERM_CODE):
username = (request.data.get('username') or request.data.get('phone') or '').strip()
if not username:
return None, None, Response({'code': 400, 'msg': '缺少username'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return None, None, permissions
codes = set(permissions or [])
user = getattr(request, 'user', None)
if getattr(user, 'IsSuperuser', False) or '000001' in codes:
return kefu, permissions, None
if perm_code not in codes:
return None, None, Response({'code': 403, 'msg': f'无权限(需要 {perm_code}'})
return kefu, permissions, None
class RoleAgreementAdminView(APIView):
"""
POST /jituan/houtai/role-agreement
action: get | save | publish | upload
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser, MultiPartParser, FormParser]
def post(self, request):
kefu, permissions, err = _auth(request)
if err:
return err
action = (request.data.get('action') or 'get').strip().lower()
operator = getattr(getattr(request, 'user', None), 'UserUID', '') or ''
if action == 'get':
return Response({'code': 0, 'msg': 'ok', 'data': svc.build_admin_payload(request)})
if action == 'save':
data, msg = svc.save_agreement(request, request.data, operator=operator)
if msg:
return Response({'code': 400, 'msg': msg})
return Response({'code': 0, 'msg': '已保存', 'data': data})
if action == 'publish':
data, msg = svc.publish_agreement(request, request.data, operator=operator)
if msg:
return Response({'code': 400, 'msg': msg})
return Response({'code': 0, 'msg': '已发布新版本', 'data': data})
if action == 'upload':
f = request.FILES.get('file')
if not f:
return Response({'code': 400, 'msg': '缺少 file'})
club_id = resolve_club_id_from_request(request) or ''
path, msg = svc.upload_agreement_image(f, club_id, kind='content')
if msg:
return Response({'code': 400, 'msg': msg})
return Response({'code': 0, 'msg': '上传成功', 'data': {'url': path, 'path': path}})
return Response({'code': 400, 'msg': '无效 action'})

View File

@@ -70,13 +70,12 @@ def process_expired_order(self, dingdan_id):
# 获取打手扩展表
dashou_profile = getattr(dashou_user, 'DashouProfile', None)
if dashou_profile:
# 使用F表达式原子更新
UserDashou.query.filter(pk=dashou_profile.pk).update(
chengjiaozongliang=F('chengjiaozongliang') + 1,
yue=F('yue') + dashou_fencheng,
zonge=F('zonge') + dashou_fencheng,
jinrishouyi=F('jinrishouyi') + dashou_fencheng,
jinyueshouyi=F('jinyueshouyi') + dashou_fencheng
from jituan.services.fund_freeze import credit_dashou_order_settle
credit_dashou_order_settle(
order=order,
dashou_profile=dashou_profile,
user_id=dashou_id,
amount=dashou_fencheng,
)
updated_dashou = True
logger.info(f"更新打手{dashou_id}信息成功")

View File

@@ -613,23 +613,13 @@ class AdQiangZhiJieDan(APIView):
# 获取打手扩展表
dashou_profile = dashou_user.DashouProfile
# 更新打手扩展表数据
# ✅ 确认字段chengjiaozongliang 成交单总量
dashou_profile.chengjiaozongliang += 1
# ✅ 确认字段yue 可提现余额
dashou_profile.yue += dashou_fencheng
# ✅ 确认字段zonge 打单赚取总额
dashou_profile.zonge += dashou_fencheng
# ✅ 确认字段jinrishouyi 今日收益金额
dashou_profile.jinrishouyi += dashou_fencheng
# ✅ 确认字段jinyueshouyi 今月收益金额
dashou_profile.jinyueshouyi += dashou_fencheng
dashou_profile.save()
from jituan.services.fund_freeze import credit_dashou_order_settle
credit_dashou_order_settle(
order=dingdan_obj,
dashou_profile=dashou_profile,
user_id=jiedan_dashou_id,
amount=dashou_fencheng,
)
logger.info(f"强制结单成功,打手{jiedan_dashou_id}结算分成:{dashou_fencheng}")
except User.DoesNotExist:
@@ -789,23 +779,13 @@ class AdJuJueTuiKuan(APIView):
# 获取打手扩展表
dashou_profile = dashou_user.DashouProfile
# 更新打手扩展表数据
# ✅ 确认字段chengjiaozongliang 成交单总量
dashou_profile.chengjiaozongliang += 1
# ✅ 确认字段yue 可提现余额
dashou_profile.yue += dashou_fencheng
# ✅ 确认字段zonge 打单赚取总额
dashou_profile.zonge += dashou_fencheng
# ✅ 确认字段jinrishouyi 今日收益金额
dashou_profile.jinrishouyi += dashou_fencheng
# ✅ 确认字段jinyueshouyi 今月收益金额
dashou_profile.jinyueshouyi += dashou_fencheng
dashou_profile.save()
from jituan.services.fund_freeze import credit_dashou_order_settle
credit_dashou_order_settle(
order=dingdan_obj,
dashou_profile=dashou_profile,
user_id=jiedan_dashou_id,
amount=dashou_fencheng,
)
logger.info(f"拒绝退款成功,打手{jiedan_dashou_id}结算分成:{dashou_fencheng}")
except User.DoesNotExist:

View File

@@ -432,21 +432,14 @@ class JiedanView(APIView):
dashou_kuozhan = None
if dashou_kuozhan:
# 更新四个金额字段
dashou_kuozhan.zonge = (dashou_kuozhan.zonge or Decimal('0.00')) + dashou_fencheng
dashou_kuozhan.yue = (dashou_kuozhan.yue or Decimal('0.00')) + dashou_fencheng
dashou_kuozhan.jinrishouyi = (dashou_kuozhan.jinrishouyi or Decimal(
'0.00')) + dashou_fencheng
dashou_kuozhan.jinyueshouyi = (dashou_kuozhan.jinyueshouyi or Decimal(
'0.00')) + dashou_fencheng
# 成交总量加1
dashou_kuozhan.chengjiaozongliang = (dashou_kuozhan.chengjiaozongliang or 0) + 1
# 状态改为数字1
dashou_kuozhan.zhuangtai = 1
dashou_kuozhan.save()
from jituan.services.fund_freeze import credit_dashou_order_settle
credit_dashou_order_settle(
order=dingdan,
dashou_profile=dashou_kuozhan,
user_id=jiedan_dashou_id,
amount=dashou_fencheng,
set_idle_status=True,
)
# 8. 更新订单状态为已完成状态3
dingdan.Status = 3
@@ -749,13 +742,14 @@ class JiedanView2(APIView):
if dashou_user:
try:
dashou_ext = dashou_user.DashouProfile
dashou_ext.zonge = (dashou_ext.zonge or Decimal('0.00')) + dashou_fencheng
dashou_ext.yue = (dashou_ext.yue or Decimal('0.00')) + dashou_fencheng
dashou_ext.jinrishouyi = (dashou_ext.jinrishouyi or Decimal('0.00')) + dashou_fencheng
dashou_ext.jinyueshouyi = (dashou_ext.jinyueshouyi or Decimal('0.00')) + dashou_fencheng
dashou_ext.chengjiaozongliang = (dashou_ext.chengjiaozongliang or 0) + 1
dashou_ext.zhuangtai = 1
dashou_ext.save()
from jituan.services.fund_freeze import credit_dashou_order_settle
credit_dashou_order_settle(
order=order,
dashou_profile=dashou_ext,
user_id=jiedan_dashou_id,
amount=dashou_fencheng,
set_idle_status=True,
)
except UserDashou.DoesNotExist:
pass

View File

@@ -880,13 +880,13 @@ class ShangjiaJiesuanView(APIView):
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
# 原子更新打手数据(使用 F 表达式
UserDashou.query.filter(id=dashou.id).update(
chengjiaozongliang=F('chengjiaozongliang') + 1,
yue=F('yue') + dashou_fencheng,
zonge=F('zonge') + dashou_fencheng,
jinrishouyi=F('jinrishouyi') + dashou_fencheng,
jinyueshouyi=F('jinyueshouyi') + dashou_fencheng,
# 原子更新打手数据(可提现走冻结网关;未开启时与原先全额进 yue 一致
from jituan.services.fund_freeze import credit_dashou_order_settle
credit_dashou_order_settle(
order=order,
dashou_profile=dashou,
user_id=jiedan_dashou_id,
amount=dashou_fencheng,
)
# 更新商家成交单量

View File

@@ -379,13 +379,14 @@ class ShopForceCompleteView(APIView):
try:
dashou_user = User.query.select_related('DashouProfile').get(UserUID=dashou_id)
dashou_profile = dashou_user.DashouProfile
dashou_profile.chengjiaozongliang += 1
dashou_profile.yue += dashou_fencheng
dashou_profile.zonge += dashou_fencheng
dashou_profile.jinrishouyi += dashou_fencheng
dashou_profile.jinyueshouyi += dashou_fencheng
dashou_profile.zhuangtai = 1 # 空闲
dashou_profile.save()
from jituan.services.fund_freeze import credit_dashou_order_settle
credit_dashou_order_settle(
order=order,
dashou_profile=dashou_profile,
user_id=dashou_id,
amount=dashou_fencheng,
set_idle_status=True,
)
except (User.DoesNotExist, ObjectDoesNotExist):
logger.warning(f"本地订单结算:打手 {dashou_id} 不存在,跳过打手更新")

View File

@@ -0,0 +1,133 @@
# Generated manually for fund freeze P0 fields on role profiles
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0006_yonghu_c_yaoqing'),
]
operations = [
migrations.AddField(
model_name='userdashou',
name='dongjie_yue',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='冻结资金池'),
),
migrations.AddField(
model_name='userdashou',
name='risk_fine_rate',
field=models.DecimalField(decimal_places=6, default=0, max_digits=8, verbose_name='窗口罚款率'),
),
migrations.AddField(
model_name='userdashou',
name='risk_complaint_rate',
field=models.DecimalField(decimal_places=6, default=0, max_digits=8, verbose_name='窗口投诉率'),
),
migrations.AddField(
model_name='userdashou',
name='risk_completed_orders',
field=models.IntegerField(default=0, verbose_name='窗口完成单量'),
),
migrations.AddField(
model_name='userdashou',
name='risk_valid_invites',
field=models.IntegerField(default=0, verbose_name='窗口有效邀请数'),
),
migrations.AddField(
model_name='userdashou',
name='risk_stat_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='风险快照时间'),
),
migrations.AddField(
model_name='userguanshi',
name='dongjie_yue',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='冻结资金池'),
),
migrations.AddField(
model_name='userguanshi',
name='risk_fine_rate',
field=models.DecimalField(decimal_places=6, default=0, max_digits=8, verbose_name='窗口罚款率'),
),
migrations.AddField(
model_name='userguanshi',
name='risk_complaint_rate',
field=models.DecimalField(decimal_places=6, default=0, max_digits=8, verbose_name='窗口投诉率'),
),
migrations.AddField(
model_name='userguanshi',
name='risk_completed_orders',
field=models.IntegerField(default=0, verbose_name='窗口完成单量'),
),
migrations.AddField(
model_name='userguanshi',
name='risk_valid_invites',
field=models.IntegerField(default=0, verbose_name='窗口有效邀请数'),
),
migrations.AddField(
model_name='userguanshi',
name='risk_stat_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='风险快照时间'),
),
migrations.AddField(
model_name='userzuzhang',
name='dongjie_yue',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='冻结资金池'),
),
migrations.AddField(
model_name='userzuzhang',
name='risk_fine_rate',
field=models.DecimalField(decimal_places=6, default=0, max_digits=8, verbose_name='窗口罚款率'),
),
migrations.AddField(
model_name='userzuzhang',
name='risk_complaint_rate',
field=models.DecimalField(decimal_places=6, default=0, max_digits=8, verbose_name='窗口投诉率'),
),
migrations.AddField(
model_name='userzuzhang',
name='risk_completed_orders',
field=models.IntegerField(default=0, verbose_name='窗口完成单量'),
),
migrations.AddField(
model_name='userzuzhang',
name='risk_valid_invites',
field=models.IntegerField(default=0, verbose_name='窗口有效邀请数'),
),
migrations.AddField(
model_name='userzuzhang',
name='risk_stat_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='风险快照时间'),
),
migrations.AddField(
model_name='usershenheguan',
name='dongjie_yue',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='冻结资金池'),
),
migrations.AddField(
model_name='usershenheguan',
name='risk_fine_rate',
field=models.DecimalField(decimal_places=6, default=0, max_digits=8, verbose_name='窗口罚款率'),
),
migrations.AddField(
model_name='usershenheguan',
name='risk_complaint_rate',
field=models.DecimalField(decimal_places=6, default=0, max_digits=8, verbose_name='窗口投诉率'),
),
migrations.AddField(
model_name='usershenheguan',
name='risk_completed_orders',
field=models.IntegerField(default=0, verbose_name='窗口完成单量'),
),
migrations.AddField(
model_name='usershenheguan',
name='risk_valid_invites',
field=models.IntegerField(default=0, verbose_name='窗口有效邀请数'),
),
migrations.AddField(
model_name='usershenheguan',
name='risk_stat_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='风险快照时间'),
),
]

View File

@@ -158,6 +158,13 @@ class UserDashou(QModel):
jinyueshouyi = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name='今月收益金额')
jifen = models.IntegerField(default=0, verbose_name='打手积分')
yajin= models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='打手押金')
# 资金冻结池 + 风险快照(冻结功能;未开启俱乐部保持 0不影响现网
dongjie_yue = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='冻结资金池')
risk_fine_rate = models.DecimalField(max_digits=8, decimal_places=6, default=0, verbose_name='窗口罚款率')
risk_complaint_rate = models.DecimalField(max_digits=8, decimal_places=6, default=0, verbose_name='窗口投诉率')
risk_completed_orders = models.IntegerField(default=0, verbose_name='窗口完成单量')
risk_valid_invites = models.IntegerField(default=0, verbose_name='窗口有效邀请数')
risk_stat_at = models.DateTimeField(null=True, blank=True, verbose_name='风险快照时间')
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
last_tixian_date = models.DateField(null=True, blank=True, verbose_name='最近提现日期') # 用于重置今日提现金额
@@ -223,6 +230,12 @@ class UserGuanshi(QModel):
jinyuechongzhi = models.IntegerField(default=0, verbose_name='今月充值打手数量')
chongzhifenrun = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='充值分佣总额')
yue = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='可提现余额')
dongjie_yue = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='冻结资金池')
risk_fine_rate = models.DecimalField(max_digits=8, decimal_places=6, default=0, verbose_name='窗口罚款率')
risk_complaint_rate = models.DecimalField(max_digits=8, decimal_places=6, default=0, verbose_name='窗口投诉率')
risk_completed_orders = models.IntegerField(default=0, verbose_name='窗口完成单量')
risk_valid_invites = models.IntegerField(default=0, verbose_name='窗口有效邀请数')
risk_stat_at = models.DateTimeField(null=True, blank=True, verbose_name='风险快照时间')
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
# 新增:首次分红金额(针对特殊用户)
fenhong_yici = models.DecimalField(max_digits=10,decimal_places=2,default=0.00,verbose_name='首次分红金额')
@@ -266,6 +279,12 @@ class UserZuzhang(QModel):
fenyong_zonge = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='分佣总额(元)')
# 可提现金额
ketixian_jine = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='可提现金额(元)')
dongjie_yue = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='冻结资金池')
risk_fine_rate = models.DecimalField(max_digits=8, decimal_places=6, default=0, verbose_name='窗口罚款率')
risk_complaint_rate = models.DecimalField(max_digits=8, decimal_places=6, default=0, verbose_name='窗口投诉率')
risk_completed_orders = models.IntegerField(default=0, verbose_name='窗口完成单量')
risk_valid_invites = models.IntegerField(default=0, verbose_name='窗口有效邀请数')
risk_stat_at = models.DateTimeField(null=True, blank=True, verbose_name='风险快照时间')
# 邀请总数
yaoqing_zongshu = models.IntegerField(default=0, verbose_name='邀请总数')
# 今日分佣
@@ -315,6 +334,12 @@ class UserShenheguan(QModel):
tongguo_zongshu = models.IntegerField(default=0, verbose_name='通过总数')
yue = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='可提现余额')
zonge = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='赚取总额')
dongjie_yue = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='冻结资金池')
risk_fine_rate = models.DecimalField(max_digits=8, decimal_places=6, default=0, verbose_name='窗口罚款率')
risk_complaint_rate = models.DecimalField(max_digits=8, decimal_places=6, default=0, verbose_name='窗口投诉率')
risk_completed_orders = models.IntegerField(default=0, verbose_name='窗口完成单量')
risk_valid_invites = models.IntegerField(default=0, verbose_name='窗口有效邀请数')
risk_stat_at = models.DateTimeField(null=True, blank=True, verbose_name='风险快照时间')
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间')

View File

@@ -337,6 +337,16 @@ def validate_withdraw_eligibility(user_main, leixing, jine):
if not fadan_ok:
return False, fadan_msg, {}
# 投诉未结清 / 管事被诉牵连上级组长(仅俱乐部开关打开时)
try:
from gongdan.services import check_complaint_blocks_withdraw
gd_ok, gd_msg, gd_extra = check_complaint_blocks_withdraw(user_main)
if not gd_ok:
return False, gd_msg, gd_extra or {}
except Exception as e:
import logging
logging.getLogger(__name__).error('投诉提现拦截检查异常: %s', e, exc_info=True)
if leixing == 1:
try:
dashou = UserDashou.query.get(user=user_main)

View File

@@ -17,7 +17,9 @@ from .views import WechatMiniProgramLoginView, UserInfoUpdateView, DashouXinxiAP
AdKfxgView,GuanshiRegisterView, WechatLoginAndGuanshiRegisterView,ZuzhangZhuceView, ZuzhangXinxiView,KefuForceCompleteView,HuoQuYaoQingRenView, DashouJianquanView, LaobanJianquanView,\
FaKuanJiFenTongJiView, DaShouFaKuanLieBiaoView, ShangjiaCufaTongjiView, ShangjiaFakuanLieBiaoView,\
FaKuanShenSuView, FaKuanPayView,FaKuanHuitiaoView, FaKuanPayPollView, FaKuanPayFailView,KaohePayView,KaohePayCallbackView, KaohePayPollView,KaohePayFailView, TixianAssetView, GuanshiContactView,TixianQueRenAutoView, TixianShenqingV3View, TixianCallbackV3View, \
GetUserPhoneView, BindUserPhoneView, BossSearchDashouView
GetUserPhoneView, BindUserPhoneView, BossSearchDashouView, \
FundFreezeMineView, FundFreezeMyLedgerView, FundFreezeMyLedgerDetailView, \
RoleAgreementCurrentView, RoleAgreementMineView, RoleAgreementSignView
from .tixian_shenhe_views import TixianZddkshApplyView
from .paihang_views import PhbHqsjView
from .xzj_paihang_views import XzjPhbHqsjView
@@ -164,6 +166,12 @@ urlpatterns = [
path('khzfsb', KaohePayFailView.as_view(), name='考核支付失败'),
path('tixianzichan', TixianAssetView.as_view(), name='小程序提现数据获取'),
path('fund-freeze-mine', FundFreezeMineView.as_view(), name='小程序冻结资金汇总'),
path('fund-freeze-ledger', FundFreezeMyLedgerView.as_view(), name='小程序冻结资金明细'),
path('fund-freeze-ledger-detail', FundFreezeMyLedgerDetailView.as_view(), name='小程序冻结单详情'),
path('role-agreement/current', RoleAgreementCurrentView.as_view(), name='小程序角色协议当前'),
path('role-agreement/mine', RoleAgreementMineView.as_view(), name='小程序角色协议列表'),
path('role-agreement/sign', RoleAgreementSignView.as_view(), name='小程序角色协议签署'),
path('guanshi_contact', GuanshiContactView.as_view(), name='获取组长'),

View File

@@ -62,6 +62,18 @@ from .tixian import (
generate_tixian_id,
)
from .fund_freeze_mp import (
FundFreezeMineView,
FundFreezeMyLedgerDetailView,
FundFreezeMyLedgerView,
)
from .role_agreement_mp import (
RoleAgreementCurrentView,
RoleAgreementMineView,
RoleAgreementSignView,
)
from .admin import (
AdGetOrderList,
AdGetOrderTypes,

View File

@@ -1869,6 +1869,7 @@ class ZuzhangZhuceView(APIView):
guanshi_count = UserGuanshi.query.filter(yaoqingren=user.UserUID).count()
return {
'ketixian': str(zuzhang.ketixian_jine), # 可提现余额
'dongjie_yue': str(getattr(zuzhang, 'dongjie_yue', 0) or 0),
'guanshiCount': guanshi_count, # 邀请管事数
'fenhongZonge': str(zuzhang.fenyong_zonge) # 分红总额
}
@@ -1894,6 +1895,7 @@ class ZuzhangXinxiView(APIView):
guanshi_count = UserGuanshi.query.filter(yaoqingren=user.UserUID).count()
data = {
'ketixian': str(zuzhang.ketixian_jine),
'dongjie_yue': str(getattr(zuzhang, 'dongjie_yue', 0) or 0),
'guanshiCount': guanshi_count,
'fenhongZonge': str(zuzhang.fenyong_zonge)
}

View File

@@ -0,0 +1,294 @@
"""小程序资金冻结只读接口。
仅返回当前登录用户自己的冻结汇总/明细,不改余额、不触发解冻。
字段均为增量返回,旧前端忽略即可。
"""
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from typing import Any, Dict, List
from django.core.exceptions import ObjectDoesNotExist
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from jituan.models import ClubFundFreezeConfig, FundFreezeLedger, FundUnfreezeEvent
from jituan.services.club_user import get_user_club_id
from jituan.services.fund_freeze import (
ROLE_DASHOU,
ROLE_GUANSHI,
ROLE_SHENHEGUAN,
ROLE_ZUZHANG,
)
ROLE_LABEL = {
ROLE_DASHOU: '接单员',
ROLE_GUANSHI: '管事',
ROLE_ZUZHANG: '组长',
ROLE_SHENHEGUAN: '考核官',
}
STATUS_LABEL = {
FundFreezeLedger.STATUS_FROZEN: '冻结中',
FundFreezeLedger.STATUS_PARTIAL: '部分解冻',
FundFreezeLedger.STATUS_DONE: '已解冻',
FundFreezeLedger.STATUS_VOID: '已作废',
}
SOURCE_LABEL = {
'order_settle': '订单结算',
'fenhong': '分红入账',
'kaohe_fee': '考核费用',
}
def _money(v) -> str:
try:
return str(Decimal(str(v or 0)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP))
except Exception:
return '0.00'
def _profile_dongjie(user, attr: str) -> str:
try:
profile = getattr(user, attr)
return _money(getattr(profile, 'dongjie_yue', 0))
except (ObjectDoesNotExist, AttributeError):
return '0.00'
def _profile_withdrawable(user, attr: str, field: str = 'yue') -> str:
try:
profile = getattr(user, attr)
return _money(getattr(profile, field, 0))
except (ObjectDoesNotExist, AttributeError):
return '0.00'
def _default_freeze_reason() -> str:
return (
'部分佣金暂存为冻结保障金,用于订单售后与纠纷保障;'
'按俱乐部规则到期或达标后自动转入可提现余额,冻结中金额不可提现。'
)
def _freeze_display_reasons(club_id: str) -> Dict[str, str]:
"""按角色返回冻结说明;无配置时用统一默认文案。"""
default = _default_freeze_reason()
out = {
ROLE_DASHOU: default,
ROLE_GUANSHI: default,
ROLE_ZUZHANG: default,
ROLE_SHENHEGUAN: default,
}
if not club_id:
return out
try:
for row in ClubFundFreezeConfig.query.filter(club_id=club_id):
if row.role in out and (row.remark or '').strip():
out[row.role] = row.remark.strip()
except Exception:
pass
return out
class FundFreezeMineView(APIView):
"""
POST /yonghu/fund-freeze-mine
当前用户冻结汇总(可提现 + 冻结中,按角色)。
"""
permission_classes = [IsAuthenticated]
def post(self, request):
user = request.user
club_id = get_user_club_id(user) or ''
roles: List[Dict[str, Any]] = [
{
'role': ROLE_DASHOU,
'role_name': ROLE_LABEL[ROLE_DASHOU],
'withdrawable': _profile_withdrawable(user, 'DashouProfile', 'yue'),
'dongjie_yue': _profile_dongjie(user, 'DashouProfile'),
},
{
'role': ROLE_GUANSHI,
'role_name': ROLE_LABEL[ROLE_GUANSHI],
'withdrawable': _profile_withdrawable(user, 'GuanshiProfile', 'yue'),
'dongjie_yue': _profile_dongjie(user, 'GuanshiProfile'),
},
{
'role': ROLE_ZUZHANG,
'role_name': ROLE_LABEL[ROLE_ZUZHANG],
'withdrawable': _profile_withdrawable(user, 'ZuzhangProfile', 'ketixian_jine'),
'dongjie_yue': _profile_dongjie(user, 'ZuzhangProfile'),
},
{
'role': ROLE_SHENHEGUAN,
'role_name': ROLE_LABEL[ROLE_SHENHEGUAN],
'withdrawable': _profile_withdrawable(user, 'ShenheguanProfile', 'yue'),
'dongjie_yue': _profile_dongjie(user, 'ShenheguanProfile'),
},
]
# 只展示有身份余额或冻结的角色,避免空壳干扰
visible = [
r for r in roles
if Decimal(r['withdrawable']) > 0 or Decimal(r['dongjie_yue']) > 0
]
total_dongjie = sum((Decimal(r['dongjie_yue']) for r in roles), Decimal('0.00'))
total_withdrawable = sum((Decimal(r['withdrawable']) for r in roles), Decimal('0.00'))
reasons = _freeze_display_reasons(club_id)
display_reason = reasons.get(ROLE_DASHOU) or _default_freeze_reason()
return Response({
'code': 200,
'msg': '获取成功',
'data': {
'club_id': club_id,
'roles': visible or roles[:1],
'all_roles': roles,
'total_dongjie': _money(total_dongjie),
'total_withdrawable': _money(total_withdrawable),
'has_freeze': total_dongjie > 0,
'display_reason': display_reason,
'freeze_display_reasons': reasons,
},
})
class FundFreezeMyLedgerView(APIView):
"""
POST /yonghu/fund-freeze-ledger
当前用户冻结明细(只读分页)。
body: page, page_size, role?, status?
"""
permission_classes = [IsAuthenticated]
def post(self, request):
user = request.user
uid = user.UserUID
club_id = get_user_club_id(user) or ''
data = request.data if isinstance(request.data, dict) else {}
page = max(1, int(data.get('page') or 1))
page_size = min(50, max(1, int(data.get('page_size') or data.get('limit') or 20)))
role = (data.get('role') or '').strip().lower()
status = data.get('status')
qs = FundFreezeLedger.query.filter(user_id=uid)
if club_id:
qs = qs.filter(club_id=club_id)
if role:
qs = qs.filter(role=role)
if status not in (None, ''):
try:
qs = qs.filter(status=int(status))
except (TypeError, ValueError):
pass
total = qs.count()
rows = qs.order_by('-id')[(page - 1) * page_size: page * page_size]
items = []
for r in rows:
remain = Decimal(str(r.amount_total or 0)) - Decimal(str(r.amount_unfrozen or 0))
items.append({
'id': r.id,
'role': r.role,
'role_name': ROLE_LABEL.get(r.role, r.role),
'amount_total': _money(r.amount_total),
'amount_unfrozen': _money(r.amount_unfrozen),
'amount_remain': _money(remain),
'status': r.status,
'status_label': STATUS_LABEL.get(r.status, str(r.status)),
'frozen_at': r.frozen_at.strftime('%Y-%m-%d %H:%M') if r.frozen_at else '',
'next_eligible_at': (
r.next_eligible_at.strftime('%Y-%m-%d %H:%M') if r.next_eligible_at else ''
),
'source_type': r.source_type,
'source_label': SOURCE_LABEL.get(r.source_type, r.source_type or '入账'),
'order_id': r.order_id or '',
'freeze_reason': r.freeze_reason or (
(r.config_snapshot or {}).get('display_reason')
or (r.config_snapshot or {}).get('remark')
or ''
),
'current_step': r.current_step,
})
return Response({
'code': 200,
'msg': '获取成功',
'data': {
'club_id': club_id,
'list': items,
'total': total,
'page': page,
'page_size': page_size,
'has_more': page * page_size < total,
},
})
class FundFreezeMyLedgerDetailView(APIView):
"""
POST /yonghu/fund-freeze-ledger-detail
单笔冻结单 + 解冻事件(仅本人)。
body: ledger_id
"""
permission_classes = [IsAuthenticated]
def post(self, request):
user = request.user
data = request.data if isinstance(request.data, dict) else {}
try:
ledger_id = int(data.get('ledger_id') or data.get('id') or 0)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': 'ledger_id 无效', 'data': None}, status=400)
if ledger_id <= 0:
return Response({'code': 400, 'msg': '缺少 ledger_id', 'data': None}, status=400)
ledger = FundFreezeLedger.query.filter(id=ledger_id, user_id=user.UserUID).first()
if not ledger:
return Response({'code': 404, 'msg': '记录不存在', 'data': None}, status=404)
remain = Decimal(str(ledger.amount_total or 0)) - Decimal(str(ledger.amount_unfrozen or 0))
events = FundUnfreezeEvent.query.filter(
ledger_id=ledger.id,
).order_by('step_seq', 'id')[:50]
event_list = [{
'id': e.id,
'step_seq': e.step_seq,
'amount': _money(e.amount),
'unfrozen_at': e.unfrozen_at.strftime('%Y-%m-%d %H:%M') if e.unfrozen_at else '',
'trigger': e.trigger,
'reason': e.reason or '',
} for e in events]
return Response({
'code': 200,
'msg': '获取成功',
'data': {
'id': ledger.id,
'role': ledger.role,
'role_name': ROLE_LABEL.get(ledger.role, ledger.role),
'amount_total': _money(ledger.amount_total),
'amount_unfrozen': _money(ledger.amount_unfrozen),
'amount_remain': _money(remain),
'status': ledger.status,
'status_label': STATUS_LABEL.get(ledger.status, str(ledger.status)),
'frozen_at': ledger.frozen_at.strftime('%Y-%m-%d %H:%M') if ledger.frozen_at else '',
'next_eligible_at': (
ledger.next_eligible_at.strftime('%Y-%m-%d %H:%M')
if ledger.next_eligible_at else ''
),
'source_type': ledger.source_type,
'source_label': SOURCE_LABEL.get(ledger.source_type, ledger.source_type or '入账'),
'order_id': ledger.order_id or '',
'freeze_reason': ledger.freeze_reason or (
(ledger.config_snapshot or {}).get('display_reason')
or (ledger.config_snapshot or {}).get('remark')
or ''
),
'current_step': ledger.current_step,
'events': event_list,
},
})

View File

@@ -1020,13 +1020,13 @@ class KefuRejectRefundView(APIView):
try:
dashou_user = User.query.get(UserUID=jiedan_dashou_id)
dashou_profile = dashou_user.DashouProfile
# 增加打手相关统计数据
dashou_profile.chengjiaozongliang += 1
dashou_profile.yue += dashou_fencheng
dashou_profile.zonge += dashou_fencheng
dashou_profile.jinrishouyi += dashou_fencheng
dashou_profile.jinyueshouyi += dashou_fencheng
dashou_profile.save()
from jituan.services.fund_freeze import credit_dashou_order_settle
credit_dashou_order_settle(
order=order,
dashou_profile=dashou_profile,
user_id=jiedan_dashou_id,
amount=dashou_fencheng,
)
logger.info(f"拒绝退款:打手 {jiedan_dashou_id} 获得分成 {dashou_fencheng}")
except (User.DoesNotExist, AttributeError):
logger.warning(f"拒绝退款:打手 {jiedan_dashou_id} 不存在或扩展表缺失,跳过结算")
@@ -1739,16 +1739,13 @@ class KefuForceCompleteView(APIView):
try:
dashou_user = User.query.get(UserUID=dashou_id)
dashou_profile = dashou_user.DashouProfile
# 原子更新(避免并发重复加)
dashou_profile.chengjiaozongliang = F('chengjiaozongliang') + 1
dashou_profile.yue = F('yue') + dashou_fencheng
dashou_profile.zonge = F('zonge') + dashou_fencheng
dashou_profile.jinrishouyi = F('jinrishouyi') + dashou_fencheng
dashou_profile.jinyueshouyi = F('jinyueshouyi') + dashou_fencheng
dashou_profile.save(update_fields=[
'chengjiaozongliang', 'yue', 'zonge',
'jinrishouyi', 'jinyueshouyi'
])
from jituan.services.fund_freeze import credit_dashou_order_settle
credit_dashou_order_settle(
order=order,
dashou_profile=dashou_profile,
user_id=dashou_id,
amount=dashou_fencheng,
)
except (User.DoesNotExist, AttributeError):
return Response({'code': 400, 'msg': '打手不存在或扩展表缺失'})

View File

@@ -0,0 +1,85 @@
"""小程序角色协议只读/签署(勾选确认,无手写)。"""
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from jituan.services.club_user import get_user_club_id
from jituan.services import role_agreement as svc
class RoleAgreementCurrentView(APIView):
"""
POST /yonghu/role-agreement/current
body: { role, gate?, view? }
gate: qiangdan|register|tixian|invite — 按卡点判断是否需签
view: 1/true — 只读查看,不强制签署
"""
permission_classes = [IsAuthenticated]
def post(self, request):
role = (request.data.get('role') or '').strip().lower()
if not role:
return Response({'code': 400, 'msg': '缺少 role', 'data': None})
gate = (request.data.get('gate') or '').strip().lower()
view_raw = request.data.get('view') or request.data.get('view_only')
view_only = view_raw in (True, 'true', '1', 1, 'True')
club_id = get_user_club_id(request.user) or ''
data = svc.get_current_for_user(
club_id, request.user.UserUID, role, gate=gate, view_only=view_only,
)
return Response({'code': 200, 'msg': 'ok', 'data': data})
class RoleAgreementMineView(APIView):
"""
POST /yonghu/role-agreement/mine
body: { roles?: ['dashou','guanshi'] } — 我的协议列表(按俱乐部)
"""
permission_classes = [IsAuthenticated]
def post(self, request):
roles = request.data.get('roles')
if isinstance(roles, str):
roles = [x.strip() for x in roles.split(',') if x.strip()]
if not isinstance(roles, list):
roles = None
club_id = get_user_club_id(request.user) or ''
data = svc.list_for_user(club_id, request.user.UserUID, roles=roles)
return Response({'code': 200, 'msg': 'ok', 'data': data})
class RoleAgreementSignView(APIView):
"""
POST /yonghu/role-agreement/sign
JSON: { role, agreed, client_meta? }
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser, FormParser, MultiPartParser]
def post(self, request):
role = (request.data.get('role') or '').strip().lower()
agreed_raw = request.data.get('agreed')
agreed = agreed_raw in (True, 'true', '1', 1, 'True')
club_id = get_user_club_id(request.user) or ''
meta = request.data.get('client_meta')
if isinstance(meta, str):
try:
import json
meta = json.loads(meta)
except Exception:
meta = {'raw': meta}
if not isinstance(meta, dict):
meta = {}
data, msg = svc.sign_agreement(
club_id,
request.user.UserUID,
role,
client_meta=meta,
agreed=agreed,
)
if msg:
return Response({'code': 400, 'msg': msg, 'data': None})
return Response({'code': 200, 'msg': '签署成功', 'data': data})

View File

@@ -402,8 +402,15 @@ class TixianShenqingView(APIView):
ok, elig_msg, extra = validate_withdraw_eligibility(user_main, leixing, jine)
if not ok:
return Response({'code': 8, 'msg': elig_msg},
status=status.HTTP_400_BAD_REQUEST)
body = {'code': 8, 'msg': elig_msg}
if (extra or {}).get('code') == 'COMPLAINT_BLOCK_WITHDRAW':
body['data'] = {
'tab': extra.get('tab') or 'received',
'reason': extra.get('reason') or '',
'count': extra.get('count') or 0,
'code': 'COMPLAINT_BLOCK_WITHDRAW',
}
return Response(body, status=status.HTTP_400_BAD_REQUEST)
# 3. 根据提现类型扣款(资格已在 validate_withdraw_eligibility 校验)
nicheng = extra.get('nicheng', '')
@@ -1050,6 +1057,7 @@ class TixianAssetView(APIView):
'DashouProfile', 'ShopProfile', 'GuanshiProfile', 'ZuzhangProfile', 'ShenheguanProfile'
).get(UserUID=request.user.UserUID)
# 初始化返回数据默认全部为0
# 冻结字段为增量兼容字段:旧前端忽略;提现仍只读 *_yue / *_fenyong
data = {
'dashou_yue': '0.00',
'dashou_yajin': '0.00',
@@ -1057,6 +1065,10 @@ class TixianAssetView(APIView):
'guanshi_fenyong': '0.00',
'zuzhang_fenyong': '0.00',
'kaoheguan_fenyong': '0.00',
'dashou_dongjie_yue': '0.00',
'guanshi_dongjie_yue': '0.00',
'zuzhang_dongjie_yue': '0.00',
'kaoheguan_dongjie_yue': '0.00',
'dashou_rate': 0.00,
'dashou_yajin_rate': 0.00,
'shangjia_rate': 0.00,
@@ -1070,6 +1082,7 @@ class TixianAssetView(APIView):
dashou = user.DashouProfile
data['dashou_yue'] = str(dashou.yue)
data['dashou_yajin'] = str(dashou.yajin)
data['dashou_dongjie_yue'] = str(getattr(dashou, 'dongjie_yue', 0) or 0)
except ObjectDoesNotExist:
pass
@@ -1084,6 +1097,7 @@ class TixianAssetView(APIView):
try:
guanshi = user.GuanshiProfile
data['guanshi_fenyong'] = str(guanshi.yue)
data['guanshi_dongjie_yue'] = str(getattr(guanshi, 'dongjie_yue', 0) or 0)
except ObjectDoesNotExist:
pass
@@ -1091,6 +1105,7 @@ class TixianAssetView(APIView):
try:
zuzhang = user.ZuzhangProfile
data['zuzhang_fenyong'] = str(zuzhang.ketixian_jine)
data['zuzhang_dongjie_yue'] = str(getattr(zuzhang, 'dongjie_yue', 0) or 0)
except ObjectDoesNotExist:
pass
@@ -1098,15 +1113,21 @@ class TixianAssetView(APIView):
try:
kaoheguan = user.ShenheguanProfile
data['kaoheguan_fenyong'] = str(kaoheguan.yue)
data['kaoheguan_dongjie_yue'] = str(getattr(kaoheguan, 'dongjie_yue', 0) or 0)
except ObjectDoesNotExist:
pass
from jituan.services.club_user import get_user_club_id
from jituan.services.withdraw_config import build_tixian_asset_rates
from users.views.fund_freeze_mp import _freeze_display_reasons, _default_freeze_reason
club_id = get_user_club_id(user)
data.update(build_tixian_asset_rates(club_id))
reasons = _freeze_display_reasons(club_id or '')
data['freeze_display_reasons'] = reasons
data['freeze_display_reason'] = reasons.get('dashou') or _default_freeze_reason()
return Response({
'code': 200,
'msg': '获取成功',

View File

@@ -406,6 +406,7 @@ class DashouXinxiAPIView(APIView):
'zaixianzhuangtai': dashou_profile.zaixianzhuangtai,
'dashouzhuangtai': dashou_profile.zhuangtai,
'yongjin': str(dashou_profile.yue) if dashou_profile.yue is not None else '0.00',
'dongjie_yue': str(getattr(dashou_profile, 'dongjie_yue', 0) or 0),
'zonge': str(dashou_profile.zonge) if dashou_profile.zonge is not None else '0.00',
'yajin': str(dashou_profile.yajin) if dashou_profile.yajin is not None else '0.00',
'jiedanZongliang':dashou_profile.jiedanzongliang if dashou_profile.jiedanzongliang is not None else '0',
@@ -667,6 +668,7 @@ class GuanshiXinxiView(APIView):
'yaoqingzongshu': guanshi_profile.yaogingshuliang, # 邀请打手总数
'fenyongzonge': float(guanshi_profile.chongzhifenrun), # 充值分佣总额
'fenyongtixian': float(guanshi_profile.yue), # 可提现余额
'dongjie_yue': float(getattr(guanshi_profile, 'dongjie_yue', 0) or 0),
'yichongzhiDashou': yichongzhi_count, # 已充值打手数量
}
})