From fa6e679cbe879a72ab6640e00980d9158813be77 Mon Sep 17 00:00:00 2001 From: XingQue Date: Sun, 26 Jul 2026 20:22:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=92=E8=89=B2=E5=8D=8F=E8=AE=AE(?= =?UTF-8?q?=E5=8B=BE=E9=80=89+=E9=98=85=E8=AF=BB=E7=A7=92=E6=95=B0+?= =?UTF-8?q?=E5=8D=A1=E7=82=B9)=E4=B8=8E=E5=86=BB=E7=BB=93=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E6=8C=89=E8=BA=AB=E4=BB=BD=E8=BF=94=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增协议表/签署留痕、后台与小程序 API;去掉手写依赖;支持抢单/注册/提现等卡点配置。冻结问号文案按角色字典下发。 Co-authored-by: Cursor --- .../commands/seed_gvsdsdk_permissions.py | 6 + jituan/management/commands/seed_kefu_menu.py | 4 + .../commands/seed_role_agreement_perm.py | 119 ++++++ jituan/migrations/0018_role_agreement.py | 66 ++++ .../migrations/0019_role_agreement_gates.py | 22 ++ jituan/models.py | 57 +++ jituan/services/kefu_menu_definitions.py | 2 + jituan/services/role_agreement.py | 355 ++++++++++++++++++ jituan/urls.py | 2 + jituan/views_role_agreement.py | 70 ++++ users/urls.py | 6 +- users/views/__init__.py | 6 + users/views/fund_freeze_mp.py | 46 ++- users/views/role_agreement_mp.py | 85 +++++ users/views/tixian.py | 23 +- 15 files changed, 834 insertions(+), 35 deletions(-) create mode 100644 jituan/management/commands/seed_role_agreement_perm.py create mode 100644 jituan/migrations/0018_role_agreement.py create mode 100644 jituan/migrations/0019_role_agreement_gates.py create mode 100644 jituan/services/role_agreement.py create mode 100644 jituan/views_role_agreement.py create mode 100644 users/views/role_agreement_mp.py diff --git a/jituan/management/commands/seed_gvsdsdk_permissions.py b/jituan/management/commands/seed_gvsdsdk_permissions.py index 6632447..e87863c 100644 --- a/jituan/management/commands/seed_gvsdsdk_permissions.py +++ b/jituan/management/commands/seed_gvsdsdk_permissions.py @@ -68,6 +68,12 @@ EXTRA_PERMISSIONS = [ '查看罚款率/投诉率等解冻相关指标', '资金冻结', ), + ( + 'xycfg001', + '角色协议配置', + '配置打手/管事/组长用户协议并发布版本', + '小程序配置', + ), # ---------- 店铺管理(999 系列:列表/详情/复制/提现等) ---------- ('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'), ('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'), diff --git a/jituan/management/commands/seed_kefu_menu.py b/jituan/management/commands/seed_kefu_menu.py index c800cec..2abd771 100644 --- a/jituan/management/commands/seed_kefu_menu.py +++ b/jituan/management/commands/seed_kefu_menu.py @@ -42,3 +42,7 @@ class Command(BaseCommand): 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}')) diff --git a/jituan/management/commands/seed_role_agreement_perm.py b/jituan/management/commands/seed_role_agreement_perm.py new file mode 100644 index 0000000..d348a63 --- /dev/null +++ b/jituan/management/commands/seed_role_agreement_perm.py @@ -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}' + )) diff --git a/jituan/migrations/0018_role_agreement.py b/jituan/migrations/0018_role_agreement.py new file mode 100644 index 0000000..7727778 --- /dev/null +++ b/jituan/migrations/0018_role_agreement.py @@ -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'), + ), + ] diff --git a/jituan/migrations/0019_role_agreement_gates.py b/jituan/migrations/0019_role_agreement_gates.py new file mode 100644 index 0000000..f2e749c --- /dev/null +++ b/jituan/migrations/0019_role_agreement_gates.py @@ -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='签署卡点配置'), + ), + ] diff --git a/jituan/models.py b/jituan/models.py index 54f372f..0bf3778 100644 --- a/jituan/models.py +++ b/jituan/models.py @@ -615,3 +615,60 @@ class FundRiskStatDaily(QModel): 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'), + ] diff --git a/jituan/services/kefu_menu_definitions.py b/jituan/services/kefu_menu_definitions.py index e762f9d..47cdb22 100644 --- a/jituan/services/kefu_menu_definitions.py +++ b/jituan/services/kefu_menu_definitions.py @@ -107,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']}, diff --git a/jituan/services/role_agreement.py b/jituan/services/role_agreement.py new file mode 100644 index 0000000..8eb98b0 --- /dev/null +++ b/jituan/services/role_agreement.py @@ -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 diff --git a/jituan/urls.py b/jituan/urls.py index 9798e09..278f51f 100644 --- a/jituan/urls.py +++ b/jituan/urls.py @@ -106,6 +106,7 @@ from jituan.views_fund_freeze import ( FundFreezeLedgerView, FundFreezeRiskView, ) +from jituan.views_role_agreement import RoleAgreementAdminView urlpatterns = [ 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-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'), diff --git a/jituan/views_role_agreement.py b/jituan/views_role_agreement.py new file mode 100644 index 0000000..639c864 --- /dev/null +++ b/jituan/views_role_agreement.py @@ -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'}) diff --git a/users/urls.py b/users/urls.py index e219998..ed72ee4 100644 --- a/users/urls.py +++ b/users/urls.py @@ -18,7 +18,8 @@ from .views import WechatMiniProgramLoginView, UserInfoUpdateView, DashouXinxiAP FaKuanJiFenTongJiView, DaShouFaKuanLieBiaoView, ShangjiaCufaTongjiView, ShangjiaFakuanLieBiaoView,\ FaKuanShenSuView, FaKuanPayView,FaKuanHuitiaoView, FaKuanPayPollView, FaKuanPayFailView,KaohePayView,KaohePayCallbackView, KaohePayPollView,KaohePayFailView, TixianAssetView, GuanshiContactView,TixianQueRenAutoView, TixianShenqingV3View, TixianCallbackV3View, \ GetUserPhoneView, BindUserPhoneView, BossSearchDashouView, \ - FundFreezeMineView, FundFreezeMyLedgerView, FundFreezeMyLedgerDetailView + FundFreezeMineView, FundFreezeMyLedgerView, FundFreezeMyLedgerDetailView, \ + RoleAgreementCurrentView, RoleAgreementMineView, RoleAgreementSignView from .tixian_shenhe_views import TixianZddkshApplyView from .paihang_views import PhbHqsjView from .xzj_paihang_views import XzjPhbHqsjView @@ -168,6 +169,9 @@ urlpatterns = [ 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='获取组长'), diff --git a/users/views/__init__.py b/users/views/__init__.py index 5ffca33..efa5e75 100644 --- a/users/views/__init__.py +++ b/users/views/__init__.py @@ -68,6 +68,12 @@ from .fund_freeze_mp import ( FundFreezeMyLedgerView, ) +from .role_agreement_mp import ( + RoleAgreementCurrentView, + RoleAgreementMineView, + RoleAgreementSignView, +) + from .admin import ( AdGetOrderList, AdGetOrderTypes, diff --git a/users/views/fund_freeze_mp.py b/users/views/fund_freeze_mp.py index 42c2436..6686895 100644 --- a/users/views/fund_freeze_mp.py +++ b/users/views/fund_freeze_mp.py @@ -66,6 +66,33 @@ def _profile_withdrawable(user, attr: str, field: str = 'yue') -> str: 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 @@ -110,17 +137,8 @@ class FundFreezeMineView(APIView): 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')) - # 用户可见说明:优先打手规则,否则取任意已配置角色 - display_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 '' + reasons = _freeze_display_reasons(club_id) + display_reason = reasons.get(ROLE_DASHOU) or _default_freeze_reason() return Response({ 'code': 200, @@ -132,10 +150,8 @@ class FundFreezeMineView(APIView): 'total_dongjie': _money(total_dongjie), 'total_withdrawable': _money(total_withdrawable), 'has_freeze': total_dongjie > 0, - 'display_reason': display_reason or ( - '部分佣金暂存为冻结保障金,用于订单售后与纠纷保障;' - '按俱乐部规则到期或达标后自动转入可提现余额,冻结中金额不可提现。' - ), + 'display_reason': display_reason, + 'freeze_display_reasons': reasons, }, }) diff --git a/users/views/role_agreement_mp.py b/users/views/role_agreement_mp.py new file mode 100644 index 0000000..e0b4db0 --- /dev/null +++ b/users/views/role_agreement_mp.py @@ -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}) diff --git a/users/views/tixian.py b/users/views/tixian.py index 5bd3f3e..f771914 100644 --- a/users/views/tixian.py +++ b/users/views/tixian.py @@ -1112,29 +1112,14 @@ class TixianAssetView(APIView): from jituan.services.club_user import get_user_club_id 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) data.update(build_tixian_asset_rates(club_id)) - display_reason = '' - try: - if club_id: - 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 ( - '部分佣金暂存为冻结保障金,用于订单售后与纠纷保障;' - '按规则解冻后自动转入可提现,冻结中不可提现。' - ) + 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,