Files
Django/jituan/services/role_agreement.py
XingQue fa6e679cbe feat: 角色协议(勾选+阅读秒数+卡点)与冻结说明按身份返回
新增协议表/签署留痕、后台与小程序 API;去掉手写依赖;支持抢单/注册/提现等卡点配置。冻结问号文案按角色字典下发。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 20:22:12 +08:00

356 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""角色协议:后台配置 + 小程序勾选签署(按俱乐部隔离)。"""
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