"""角色协议:后台配置 + 小程序勾选签署(按俱乐部隔离)。""" 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