feat: 角色协议(勾选+阅读秒数+卡点)与冻结说明按身份返回

新增协议表/签署留痕、后台与小程序 API;去掉手写依赖;支持抢单/注册/提现等卡点配置。冻结问号文案按角色字典下发。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-26 20:22:12 +08:00
parent 6c96eb3bcb
commit fa6e679cbe
15 changed files with 834 additions and 35 deletions

View File

@@ -68,6 +68,12 @@ EXTRA_PERMISSIONS = [
'查看罚款率/投诉率等解冻相关指标', '查看罚款率/投诉率等解冻相关指标',
'资金冻结', '资金冻结',
), ),
(
'xycfg001',
'角色协议配置',
'配置打手/管事/组长用户协议并发布版本',
'小程序配置',
),
# ---------- 店铺管理999 系列:列表/详情/复制/提现等) ---------- # ---------- 店铺管理999 系列:列表/详情/复制/提现等) ----------
('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'), ('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'),
('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'), ('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'),

View File

@@ -42,3 +42,7 @@ class Command(BaseCommand):
call_command('seed_fund_freeze_perm') call_command('seed_fund_freeze_perm')
except Exception as e: except Exception as e:
self.stdout.write(self.style.WARNING(f'seed_fund_freeze_perm 跳过: {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,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

@@ -615,3 +615,60 @@ class FundRiskStatDaily(QModel):
indexes = [ indexes = [
models.Index(fields=['club_id', 'role', 'day'], name='idx_frsd_club_role_day'), 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

@@ -107,6 +107,8 @@ KEFU_MENU_ROW_DEFS = [
'perm_codes': ['phbj666', '8080a']}, 'perm_codes': ['phbj666', '8080a']},
{'page_id': 'miniapp.recharge-copy', 'name': '充值页文案', 'path': '/miniapp/recharge-copy', 'parent_id': 'miniapp', 'sort_order': 119, {'page_id': 'miniapp.recharge-copy', 'name': '充值页文案', 'path': '/miniapp/recharge-copy', 'parent_id': 'miniapp', 'sort_order': 119,
'perm_codes': ['8080a']}, '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', 'name': '店铺管理', 'path': '', 'parent_id': '', 'sort_order': 120},
{'page_id': 'shop.list', 'name': '店铺列表', 'path': '/shop/list', 'parent_id': 'shop', 'sort_order': 121, {'page_id': 'shop.list', 'name': '店铺列表', 'path': '/shop/list', 'parent_id': 'shop', 'sort_order': 121,
'perm_codes': ['999a', '999b', '999c', '999d', '999e']}, '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

View File

@@ -106,6 +106,7 @@ from jituan.views_fund_freeze import (
FundFreezeLedgerView, FundFreezeLedgerView,
FundFreezeRiskView, FundFreezeRiskView,
) )
from jituan.views_role_agreement import RoleAgreementAdminView
urlpatterns = [ urlpatterns = [
path('auth/wechat-login', ClubWechatLoginView.as_view(), name='jituan_wechat_login'), path('auth/wechat-login', ClubWechatLoginView.as_view(), name='jituan_wechat_login'),
@@ -120,6 +121,7 @@ urlpatterns = [
path('houtai/fund-freeze-config', FundFreezeConfigView.as_view(), name='jituan_fund_freeze_config'), 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-ledger', FundFreezeLedgerView.as_view(), name='jituan_fund_freeze_ledger'),
path('houtai/fund-freeze-risk', FundFreezeRiskView.as_view(), name='jituan_fund_freeze_risk'), 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/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/club-cert-upload', ClubCertUploadView.as_view(), name='jituan_club_cert_upload'),
path('houtai/admin-assignments', ClubAdminAssignmentListView.as_view(), name='jituan_admin_assignments'), path('houtai/admin-assignments', ClubAdminAssignmentListView.as_view(), name='jituan_admin_assignments'),

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

@@ -18,7 +18,8 @@ from .views import WechatMiniProgramLoginView, UserInfoUpdateView, DashouXinxiAP
FaKuanJiFenTongJiView, DaShouFaKuanLieBiaoView, ShangjiaCufaTongjiView, ShangjiaFakuanLieBiaoView,\ FaKuanJiFenTongJiView, DaShouFaKuanLieBiaoView, ShangjiaCufaTongjiView, ShangjiaFakuanLieBiaoView,\
FaKuanShenSuView, FaKuanPayView,FaKuanHuitiaoView, FaKuanPayPollView, FaKuanPayFailView,KaohePayView,KaohePayCallbackView, KaohePayPollView,KaohePayFailView, TixianAssetView, GuanshiContactView,TixianQueRenAutoView, TixianShenqingV3View, TixianCallbackV3View, \ FaKuanShenSuView, FaKuanPayView,FaKuanHuitiaoView, FaKuanPayPollView, FaKuanPayFailView,KaohePayView,KaohePayCallbackView, KaohePayPollView,KaohePayFailView, TixianAssetView, GuanshiContactView,TixianQueRenAutoView, TixianShenqingV3View, TixianCallbackV3View, \
GetUserPhoneView, BindUserPhoneView, BossSearchDashouView, \ GetUserPhoneView, BindUserPhoneView, BossSearchDashouView, \
FundFreezeMineView, FundFreezeMyLedgerView, FundFreezeMyLedgerDetailView FundFreezeMineView, FundFreezeMyLedgerView, FundFreezeMyLedgerDetailView, \
RoleAgreementCurrentView, RoleAgreementMineView, RoleAgreementSignView
from .tixian_shenhe_views import TixianZddkshApplyView from .tixian_shenhe_views import TixianZddkshApplyView
from .paihang_views import PhbHqsjView from .paihang_views import PhbHqsjView
from .xzj_paihang_views import XzjPhbHqsjView from .xzj_paihang_views import XzjPhbHqsjView
@@ -168,6 +169,9 @@ urlpatterns = [
path('fund-freeze-mine', FundFreezeMineView.as_view(), name='小程序冻结资金汇总'), path('fund-freeze-mine', FundFreezeMineView.as_view(), name='小程序冻结资金汇总'),
path('fund-freeze-ledger', FundFreezeMyLedgerView.as_view(), name='小程序冻结资金明细'), path('fund-freeze-ledger', FundFreezeMyLedgerView.as_view(), name='小程序冻结资金明细'),
path('fund-freeze-ledger-detail', FundFreezeMyLedgerDetailView.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='获取组长'), path('guanshi_contact', GuanshiContactView.as_view(), name='获取组长'),

View File

@@ -68,6 +68,12 @@ from .fund_freeze_mp import (
FundFreezeMyLedgerView, FundFreezeMyLedgerView,
) )
from .role_agreement_mp import (
RoleAgreementCurrentView,
RoleAgreementMineView,
RoleAgreementSignView,
)
from .admin import ( from .admin import (
AdGetOrderList, AdGetOrderList,
AdGetOrderTypes, AdGetOrderTypes,

View File

@@ -66,6 +66,33 @@ def _profile_withdrawable(user, attr: str, field: str = 'yue') -> str:
return '0.00' 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): class FundFreezeMineView(APIView):
""" """
POST /yonghu/fund-freeze-mine POST /yonghu/fund-freeze-mine
@@ -110,17 +137,8 @@ class FundFreezeMineView(APIView):
total_dongjie = sum((Decimal(r['dongjie_yue']) for r in roles), Decimal('0.00')) 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')) total_withdrawable = sum((Decimal(r['withdrawable']) for r in roles), Decimal('0.00'))
# 用户可见说明:优先打手规则,否则取任意已配置角色 reasons = _freeze_display_reasons(club_id)
display_reason = '' display_reason = reasons.get(ROLE_DASHOU) or _default_freeze_reason()
if club_id:
cfgs = list(ClubFundFreezeConfig.query.filter(club_id=club_id, enabled=True))
prefer = next((c for c in cfgs if c.role == ROLE_DASHOU), None) or (cfgs[0] if cfgs else None)
if prefer and prefer.remark:
display_reason = prefer.remark
if not display_reason:
any_cfg = ClubFundFreezeConfig.query.filter(club_id=club_id).exclude(remark='').first()
if any_cfg:
display_reason = any_cfg.remark or ''
return Response({ return Response({
'code': 200, 'code': 200,
@@ -132,10 +150,8 @@ class FundFreezeMineView(APIView):
'total_dongjie': _money(total_dongjie), 'total_dongjie': _money(total_dongjie),
'total_withdrawable': _money(total_withdrawable), 'total_withdrawable': _money(total_withdrawable),
'has_freeze': total_dongjie > 0, 'has_freeze': total_dongjie > 0,
'display_reason': display_reason or ( 'display_reason': display_reason,
'部分佣金暂存为冻结保障金,用于订单售后与纠纷保障;' 'freeze_display_reasons': reasons,
'按俱乐部规则到期或达标后自动转入可提现余额,冻结中金额不可提现。'
),
}, },
}) })

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

@@ -1112,29 +1112,14 @@ class TixianAssetView(APIView):
from jituan.services.club_user import get_user_club_id from jituan.services.club_user import get_user_club_id
from jituan.services.withdraw_config import build_tixian_asset_rates from jituan.services.withdraw_config import build_tixian_asset_rates
from jituan.models import ClubFundFreezeConfig from users.views.fund_freeze_mp import _freeze_display_reasons, _default_freeze_reason
club_id = get_user_club_id(user) club_id = get_user_club_id(user)
data.update(build_tixian_asset_rates(club_id)) data.update(build_tixian_asset_rates(club_id))
display_reason = '' reasons = _freeze_display_reasons(club_id or '')
try: data['freeze_display_reasons'] = reasons
if club_id: data['freeze_display_reason'] = reasons.get('dashou') or _default_freeze_reason()
prefer = ClubFundFreezeConfig.query.filter(
club_id=club_id, role='dashou', enabled=True,
).first()
if not prefer:
prefer = ClubFundFreezeConfig.query.filter(
club_id=club_id, enabled=True,
).exclude(remark='').first()
if prefer:
display_reason = prefer.remark or ''
except Exception:
pass
data['freeze_display_reason'] = display_reason or (
'部分佣金暂存为冻结保障金,用于订单售后与纠纷保障;'
'按规则解冻后自动转入可提现,冻结中不可提现。'
)
return Response({ return Response({
'code': 200, 'code': 200,